{"repo": "benedictking/ccx", "files": {"docs/providers/mimo.md": {"ebe33699466c4c93f605d8b16e5eb0567c127bd5": {"t": 1785595131.4993916, "n": 3351, "h": "48c11e16947af217", "c": "# 小米 MiMo 配置指南\n\n## 获取 API Key\n\nMiMo 提供两种访问方式,凭证获取路径不同:\n\n### 订阅套餐(推荐)\n\n1. 访问 [小米 MiMo 平台](https://platform.xiaomimimo.com/)\n2. 在 [套餐管理](https://platform.xiaomimimo.com/console/plan-manage) 页面选择并订阅套餐\n3. 前往 [订阅管理](https://platform.xiaomimimo.com/console/plan-manage) 页面获取凭证:\n - **API Key**:格式为 `tp-xxxxx`\n - **Base URL**:根据你选择的集群和协议类型(见下方)\n\n### 账号余额\n\n1. 访问 [小米 MiMo 平台](https://platform.xiaomimimo.com/)\n2. 在 [余额页面](https://platform.xiaomimimo.com/console/balance) 充值\n3. 在 [API Keys](https://platform.xiaomimimo.com/console/api-keys) 页面创建 API Key\n\n## 在 CCX 中添加渠道\n\n根据你的访问方式选择对应的 Base URL。\n\n### 订阅套餐\n\nBase URL 按集群和协议分类,以 [订阅管理](https://platform.xiaomimimo.com/console/plan-manage) 页面展示为准:\n\n#### OpenAI 兼容协议\n\n| 集群 | Base URL |\n|------|----------|\n| 中国 | `https://token-plan-cn.xiaomimimo.com/v1` |\n| 新加坡 | `https://token-plan-sgp.xiaomimimo.com/v1` |\n| 欧洲 | `https://token-plan-ams.xiaomimimo.com/v1` |\n\n#### Anthropic 兼容协议\n\n| 集群 | Base URL |\n|------|----------|\n| 中国 | `https://token-plan-cn.xiaomimimo.com/anthropic` |\n| 新加坡 | `https://token-plan-sgp.xiaomimimo.com/anthropic` |\n| 欧洲 | `https://token-plan-ams.xiaomimimo.com/anthropic` |\n\n::: tip\n使用 Claude Code CLI 时,可选择 Anthropic 兼容协议端点,服务类型选 `claude`。\n:::\n\n### 账号余额\n\n| 字段 | 值 |\n|------|-----|\n| 服务类型 | `openai` |\n| Base URL | `https://api.xiaomimimo.com/v1` |\n| API Keys | 你在 [API Keys](https://platform.xiaomimimo.com/console/api-keys) 页面创建的 Key |\n\n### 配置步骤(以订阅套餐 OpenAI 协议中国集群为例)\n\n1. 进入 CCX 管理界面,选择 **Chat** 入口\n2. 点击「添加渠道」\n3. 填写以下信息:\n - **名称**:`MiMo`\n - **服务类型**:选择 `OpenAI Chat`\n - **Base URL**:`https://token-plan-cn.xiaomimimo.com/v1`\n - **API Keys**:粘贴你的 `tp-xxxxx` 订阅 Key\n4. 点击保存\n\n### 模型白名单(可选)\n\n```\nmimo-v2.5-pro\nmimo-v2.5\nmimo-v2-flash\n```\n\n### 模型映射(可选)\n\n```json\n{\n \"mimo-pro\": \"mimo-v2.5-pro\",\n \"mimo\": \"mimo-v2.5\",\n \"mimo-flash\": \"mimo-v2-flash\"\n}\n```\n\n## 可用模型\n\n| 模型 | 说明 |\n|------|------|\n| `mimo-v2.5-pro` | 最新旗舰,1.02T 总参 / 42B 激活 |\n| `mimo-v2.5` | 310B 总参 / 15B 激活,原生多模态 |\n| `mimo-v2-flash` | 309B 总参 / 15B 激活,高速推理 |\n\n## 注意事项\n\n- MiMo 通过兼容 OpenAI 协议的平台访问\n- MiMo 是推理模型,支持 `reasoning_content` 字段返回思考过程\n\n### 视觉支持\n\nMiMo 各模型的视觉支持情况:\n\n| 模型 | 视觉支持 |\n|------|----------|\n| `MiMo-V2.5-Pro` | 不支持 |\n| `MiMo-V2.5` | 支持(原生多模态) |\n| `MiMo-V2-Flash` | 不支持 |\n\n::: warning\n`MiMo-V2.5-Pro` 不支持图片输入。如果需要处理包含图片的请求,必须配置**视觉回退模型**。\n:::\n\n**配置方式:** 编辑渠道,在「视觉回退模型」字段填入 `MiMo-V2.5`。当请求包含图片且目标模型(如 `MiMo-V2.5-Pro`)不支持视觉时,CCX 会自动使用 `MiMo-V2.5` 替代模型处理该请求。\n\n![MiMo 渠道配置:模型重定向将 haiku/opus/sonnet 统一映射到 mimo-v2.5-pro;下方「视觉回退模型」字段填入 mimo-v2.5,当请求包含图片且目标模型不支持视觉时自动切换到该模型](/screenshots/channel-mimo-config.png)\n\n
MiMo 渠道配置 — 模型重定向(haiku/opus/sonnet → mimo-v2.5-pro)与视觉回退模型(mimo-v2.5)
\n\n如果留空视觉回退模型,包含图片的请求将跳过该渠道,failover 到下一个支持视觉的渠道。\n\n### 回传思考内容\n\nMiMo 作为推理模型,思考过程中产生的 `reasoning_content` 需要回传给 API,否则会返回 HTTP 400 错误。\n\n**必须启用:** 编辑渠道时打开「回传思考内容」开关(`PassbackReasoningContent`)。\n\n启用后 CCX 会自动处理:\n- **请求方向:** 为缺少 `thinking` 块的 assistant 消息注入占位符,满足 MiMo 的回传要求\n- **响应方向:** 将上游返回的 `reasoning_content` 转换为 Claude 原生的 `thinking` 内容块,下游客户端可正常解析\n\n::: warning\n不开启此开关会导致包含历史对话的推理请求失败(HTTP 400)。\n:::\n\n### 推荐的模型映射\n\n将 Claude 模型名映射到 MiMo 模型:\n\n| 请求模型 | 重定向到 | 说明 |\n|----------|----------|------|\n| `haiku` | `mimo-v2.5-pro` | 旗舰推理 |\n| `opus` | `mimo-v2.5-pro` | 旗舰推理 |\n| `sonnet` | `mimo-v2.5-pro` | 旗舰推理 |\n\n::: tip\n所有 Claude 模型统一映射到 `MiMo-V2.5-Pro` 以获得最佳推理能力。配合视觉回退模型 `MiMo-V2.5` 使用,图片请求会自动切换到支持视觉的模型。\n:::\n"}}, "backend-go/internal/config/provider_templates.go": {"ebe33699466c4c93f605d8b16e5eb0567c127bd5": {"t": 1785595146.295748, "n": 25135, "h": "4b8622458f235472", "c": "package config\n\nimport (\n\t\"math\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n)\n\n// ProviderTemplate 描述一个已知 provider 的模板化添加配置。\n//\n// 设计目标:用户只需选择 provider + 输入 API Key,系统按 key 前缀选候选 baseURL,\n// 探测验证可用性后自动创建渠道,无需手填 baseURL / 选协议 / 配兼容开关。\n//\n// baseURL 按上游原生协议区分端点:Claude 请求优先走原生 Claude 入口;\n// Chat / Codex Responses 请求走 OpenAI Chat 兼容入口,由后端做协议转换。\n// ProviderTemplate 只描述来源和端点,不再承载 channel-presets 兼容开关;\n// autoManaged 渠道的模型与能力差异由后端智能调度/ModelResolver 处理。\ntype ProviderTemplate struct {\n\tProviderID string `json:\"providerId\"` // \"mimo\" / \"deepseek\" / ...\n\tAliases []string `json:\"aliases,omitempty\"`\n\tDisplayName string `json:\"displayName\"` // \"小米 MiMo\"\n\tDescription string `json:\"description\"` // key 前缀说明等\n\tChannelKind string `json:\"channelKind\"` // 默认 route 的渠道类型,兼容旧前端\n\tServiceType string `json:\"serviceType\"` // 默认 route 的服务类型,兼容旧前端\n\tOriginType string `json:\"originType\"` // official_api / official_token_plan / relay\n\tOriginTier string `json:\"originTier\"` // first / second\n\tKeyPrefixRules []KeyPrefixRule `json:\"keyPrefixRules\"` // key 前缀 → plan 判别\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 默认 route 的候选 baseURL\n\tRoutes []ProviderRoute `json:\"routes,omitempty\"`\n\t// ModelCostMultipliers 描述同一 provider 套餐内每次模型调用的相对消耗。\n\t// key 支持模型精确 ID 或通配符;值越低越省,只在质量与实测表现相同后参与选优。\n\tModelCostMultipliers map[string]float64 `json:\"modelCostMultipliers,omitempty\"`\n\t// ModelQualityPriorities 描述 provider 已确认的同质量档模型能力顺序,值越高越强。\n\t// 该映射允许不完整;解析器仅在同档候选全部命中时比较,避免未知模型被无依据降权。\n\tModelQualityPriorities map[string]int `json:\"modelQualityPriorities,omitempty\"`\n}\n\n// ProviderRoute 描述同一 provider 在某个 CCX 渠道协议下使用的原生上游入口。\ntype ProviderRoute struct {\n\tChannelKind string `json:\"channelKind\"` // \"messages\" / \"chat\" / \"responses\"\n\tServiceType string `json:\"serviceType\"` // \"claude\" / \"openai\" / \"responses\"\n\tDescription string `json:\"description,omitempty\"` // route 说明,仅展示/诊断\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 该 route 的候选 baseURL\n\t// SupportedModels 限定该 route 端点实际接受的模型,语义与 UpstreamConfig.SupportedModels 一致\n\t// (支持精确匹配、prefix* / *suffix / *contains*,以及 ! 前缀排除)。\n\t//\n\t// 仅在上游同一 baseURL 下不同协议端点的模型支持范围不一致时才需要填写:\n\t// 例如 DeepSeek 的 /v1/responses 只接受 deepseek-v4-flash,而 /v1/chat/completions\n\t// 同时接受 flash 与 pro。留空表示该 route 不额外限制模型。\n\tSupportedModels []string `json:\"supportedModels,omitempty\"`\n}\n\n// KeyPrefixRule 按 API Key 前缀判别 plan 类型。\ntype KeyPrefixRule struct {\n\tPrefix string `json:\"prefix\"` // \"sk-\" / \"tp-\"\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\"\n}\n\n// ProviderCandidate 一个候选 baseURL 及其归属信息。\ntype ProviderCandidate struct {\n\tBaseURL string `json:\"baseUrl\"` // 完整 baseURL,如 https://api.xiaomimimo.com/anthropic\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\" / \"\"\n\tRegion string `json:\"region\"` // \"cn\" / \"sgp\" / \"ams\" / \"global\" / \"\"\n\tPriority int `json:\"priority\"` // 同 plan 内探测优先级(数字越小越先探测)\n}\n\n// builtinProviderTemplates 编译期内置的已知 provider 模板。\n//\n// URL 来源(2026-07 联网核实官方文档):\n// - MiMo: https://mimo.mi.com/docs(按量 sk- / Token Plan tp-,TP 分 cn/sgp/ams 三区域集群)\n// - DeepSeek: https://api-docs.deepseek.com(Anthropic 兼容 /anthropic)\n// - Kimi: https://api.moonshot.ai/anthropic(全球)/ https://api.moonshot.cn/anthropic(中国)\n// - GLM: https://open.bigmodel.cn/api/anthropic(Claude)与 /api/paas/v4(OpenAI)\n// - 火山方舟: https://ark.cn-beijing.volces.com/api/plan(Agent Plan)与 /api/coding(Coding Plan)\n//\n// Claude route 的 baseURL 使用 Anthropic 兼容入口且不带 /v1(claude provider 会自动补 /v1/messages)。\n// Chat/Responses route 使用 OpenAI Chat 兼容入口,由 provider 自动补协议端点。\nvar builtinProviderTemplates = []ProviderTemplate{\n\t{\n\t\tProviderID: \"mimo\",\n\t\tDisplayName: \"小米 MiMo\",\n\t\tDescription: \"sk- 按量付费 / tp- Token Plan 订阅(Claude 走 /anthropic,Chat/Codex/Gemini 走 /v1)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"sk-\", PlanTag: \"payg\"},\n\t\t\t{Prefix: \"tp-\", PlanTag: \"token_plan\"},\n\t\t},\n\t\tCandidates: mimoClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages 原生 Anthropic 兼容入口\",\n\t\t\t\tCandidates: mimoClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"deepseek\",\n\t\tDisplayName: \"DeepSeek\",\n\t\tDescription: \"DeepSeek 官方 API(Claude Messages、OpenAI Chat 与 Responses 三协议原生兼容;Responses 仅支持 deepseek-v4-flash)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: deepseekClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages Anthropic 兼容入口\",\n\t\t\t\tCandidates: deepseekClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: deepseekOpenAICandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"responses\",\n\t\t\t\tDescription: \"OpenAI Responses 原生入口(仅 deepseek-v4-flash;v4-pro 预计 2026-08 初支持)\",\n\t\t\t\tCandidates: deepseekResponsesCandidates(),\n\t\t\t\t// 上游对不支持的模型返回 400 而非忽略,因此在渠道层就限定为正向白名单:\n\t\t\t\t// 未知新模型不会被误放进该端点,v4-pro 上线后在此追加即可。\n\t\t\t\tSupportedModels: []string{\"deepseek-v4-flash\"},\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"volcengine\",\n\t\tAliases: []string{\"volc-ark\"},\n\t\tDisplayName: \"火山方舟 Agent/Coding Plan\",\n\t\tDescription: \"ark- 套餐推理 Key。Agent Plan 支持 AFP 感知路由(详见 volcengine_afp_pricing.go);Coding Plan 计费规则未核验,仅使用通用定价。模型发现需为每个 Key 绑定火山云 Access Key。\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"ark-\", PlanTag: \"personal_plan\"},\n\t\t},\n\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Agent/Coding Plan Claude Messages 入口\",\n\t\t\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Agent/Coding Plan OpenAI Chat Completions 入口\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"kimi\",\n\t\tAliases: []string{\"kimi-code\"},\n\t\tDisplayName: \"Kimi (Moonshot)\",\n\t\tDescription: \"Moonshot Kimi 官方 API(按量与 Coding Plan,自动判别可用入口)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: kimiClaudeCandidates(),\n\t\tRoutes: standardProviderRoutes(\"claude\", kimiClaudeCandidates(), kimiOpenAICandidates()),\n\t},\n\t{\n\t\tProviderID: \"glm\",\n\t\tDisplayName: \"智谱 GLM\",\n\t\tDescription: \"智谱 GLM 官方 API(自动识别 id.secret Key;按量与 Coding Plan)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: glmClaudeCandidates(),\n\t\tRoutes: standardProviderRoutes(\"claude\", glmClaudeCandidates(), glmOpenAICandidates()),\n\t},\n\tcompshareProviderTemplate(),\n\tnewProviderTemplate(\n\t\t\"sensenova\",\n\t\t\"SenseNova\",\n\t\t\"商汤日日新官方 API\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://token.sensenova.cn\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://token.sensenova.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"minimax\",\n\t\t\"MiniMax\",\n\t\t\"MiniMax 官方 API\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.minimaxi.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.minimax.chat/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"dashscope\",\n\t\t\"阿里云 DashScope\",\n\t\t\"阿里云百炼按量、Coding Plan 与 Token Plan\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", dashScopeClaudeCandidates(), dashScopeOpenAICandidates()),\n\t),\n\tnewProviderTemplateWithAliases(\n\t\t\"opencode-zen\",\n\t\t[]string{\"opencode-go\"},\n\t\t\"OpenCode Zen / Go\",\n\t\t\"OpenCode 官方 Zen 按量与 Go 订阅模型网关\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"openai\", openCodeCandidates(), openCodeCandidates()),\n\t),\n\tnewProviderTemplate(\n\t\t\"tencent-lkeap\",\n\t\t\"腾讯云 TokenHub\",\n\t\t\"腾讯云大模型 Token Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/v3\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"qianfan\",\n\t\t\"百度千帆 Coding Plan\",\n\t\t\"百度智能云千帆 Coding Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://qianfan.baidubce.com/anthropic/coding\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://qianfan.baidubce.com/v2/coding#\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"xfyun\",\n\t\t\"讯飞星辰\",\n\t\t\"科大讯飞星辰 MaaS Coding Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\txfYunRoutes(),\n\t),\n\tnewProviderTemplate(\n\t\t\"openrouter\",\n\t\t\"OpenRouter\",\n\t\t\"OpenRouter 多模型聚合平台\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://openrouter.ai/api\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://openrouter.ai/api/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"modelscope\",\n\t\t\"ModelScope 魔搭\",\n\t\t\"阿里 ModelScope 官方推理服务\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api-inference.modelscope.cn\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api-inference.modelscope.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"originrouter\",\n\t\t\"极易云 OriginRouter\",\n\t\t\"极易云多模型 API 转发平台\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.easytransnote.com/coding\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.easytransnote.com/coding/v1\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n}\n\nvar zhipuAPIKeyPattern = regexp.MustCompile(`^[A-Za-z0-9]{20,}\\.[A-Za-z0-9]{10,}$`)\n\nfunc standardProviderRoutes(messagesServiceType string, messagesCandidates, openAICandidates []ProviderCandidate) []ProviderRoute {\n\tmessagesDescription := \"Claude Messages 原生 Anthropic 兼容入口\"\n\tif messagesServiceType == \"openai\" {\n\t\tmessagesDescription = \"Claude Messages 请求转换到 OpenAI Chat Completions\"\n\t}\n\treturn []ProviderRoute{\n\t\t{\n\t\t\tChannelKind: \"messages\",\n\t\t\tServiceType: messagesServiceType,\n\t\t\tDescription: messagesDescription,\n\t\t\tCandidates: messagesCandidates,\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"chat\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\tCandidates: openAICandidates,\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"responses\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\tCandidates: openAICandidates,\n\t\t},\n\t}\n}\n\nfunc newProviderTemplate(providerID, displayName, description, originType, originTier string, routes []ProviderRoute) ProviderTemplate {\n\treturn newProviderTemplateWithAliases(providerID, nil, displayName, description, originType, originTier, routes)\n}\n\nfunc newProviderTemplateWithAliases(providerID string, aliases []string, displayName, description, originType, originTier string, routes []ProviderRoute) ProviderTemplate {\n\ttmpl := ProviderTemplate{\n\t\tProviderID: providerID,\n\t\tAliases: aliases,\n\t\tDisplayName: displayName,\n\t\tDescription: description,\n\t\tOriginType: originType,\n\t\tOriginTier: originTier,\n\t\tRoutes: routes,\n\t}\n\tif len(routes) > 0 {\n\t\ttmpl.ChannelKind = routes[0].ChannelKind\n\t\ttmpl.ServiceType = routes[0].ServiceType\n\t\ttmpl.Candidates = routes[0].Candidates\n\t}\n\treturn tmpl\n}\n\nfunc compshareProviderTemplate() ProviderTemplate {\n\ttmpl := newProviderTemplate(\n\t\t\"compshare\",\n\t\t\"优云智算套餐\",\n\t\t\"UCloud 优云智算模型套餐与聚合服务\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://cp.compshare.cn\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://cp.compshare.cn/v1\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t)\n\t// 优云智算控制台的套餐消耗次数;glm-5.2 当前为限时倍率。\n\ttmpl.ModelCostMultipliers = map[string]float64{\n\t\t\"glm-5.2\": 2,\n\t\t\"glm-5.1\": 6,\n\t\t\"MiniMax-M2.7\": 2,\n\t\t\"kimi-k2.6\": 5,\n\t\t\"deepseek-ai/DeepSeek-V3.2\": 1,\n\t\t\"deepseek-v4-flash\": 1,\n\t}\n\ttmpl.ModelQualityPriorities = map[string]int{\n\t\t\"glm-5.1\": 2,\n\t\t\"kimi-k2.6\": 1,\n\t}\n\treturn tmpl\n}\n\nfunc deepseekClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com/anthropic\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\nfunc deepseekOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\n// deepseekResponsesCandidates DeepSeek 原生 Responses 入口。\n// 官方 base_url 为 https://api.deepseek.com,端点为标准 /v1/responses。\nfunc deepseekResponsesCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com/v1/responses\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\nfunc glmClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t}\n}\n\nfunc glmOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t// # 表示该路径已经是版本根,调用方不得再自动补 /v1。\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/paas/v4#\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/coding/paas/v4#\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n\nfunc kimiClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.moonshot.cn/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://api.moonshot.ai/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t\t{BaseURL: \"https://api.kimi.com/coding\", PlanTag: \"coding_plan\", Region: \"global\", Priority: 2},\n\t}\n}\n\nfunc kimiOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.moonshot.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://api.moonshot.ai/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t\t{BaseURL: \"https://api.kimi.com/coding/v1\", PlanTag: \"coding_plan\", Region: \"global\", Priority: 2},\n\t}\n}\n\nfunc dashScopeClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://dashscope.aliyuncs.com/apps/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://coding.dashscope.aliyuncs.com/apps/anthropic\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 2},\n\t}\n}\n\nfunc dashScopeOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://dashscope.aliyuncs.com/compatible-mode/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://coding.dashscope.aliyuncs.com/v1\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 2},\n\t}\n}\n\nfunc openCodeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://opencode.ai/zen/go/v1\", PlanTag: \"subscription\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://opencode.ai/zen/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t}\n}\n\nfunc xfYunRoutes() []ProviderRoute {\n\treturn []ProviderRoute{\n\t\t{\n\t\t\tChannelKind: \"messages\",\n\t\t\tServiceType: \"claude\",\n\t\t\tDescription: \"Claude Messages Coding Plan 入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/anthropic\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"chat\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"OpenAI Chat Completions Coding Plan 入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v2\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"responses\",\n\t\t\tServiceType: \"responses\",\n\t\t\tDescription: \"OpenAI Responses Coding Plan 原生入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v1/responses\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t}\n}\n\n// ListProviderTemplates 返回所有内置 provider 模板。\nfunc ListProviderTemplates() []ProviderTemplate {\n\tout := make([]ProviderTemplate, len(builtinProviderTemplates))\n\tcopy(out, builtinProviderTemplates)\n\treturn out\n}\n\n// GetProviderTemplate 按 providerId 查找模板,未找到返回 (nil, false)。\nfunc GetProviderTemplate(providerID string) (*ProviderTemplate, bool) {\n\tproviderID = strings.ToLower(strings.TrimSpace(providerID))\n\tfor i := range builtinProviderTemplates {\n\t\ttmpl := &builtinProviderTemplates[i]\n\t\tif strings.EqualFold(tmpl.ProviderID, providerID) {\n\t\t\treturn tmpl, true\n\t\t}\n\t\tfor _, alias := range tmpl.Aliases {\n\t\t\tif strings.EqualFold(alias, providerID) {\n\t\t\t\treturn tmpl, true\n\t\t\t}\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ModelCostMultiplierForModel 返回 provider 套餐内指定模型的相对消耗倍率。\n// 精确 ID 优先,随后按最长通配符匹配;非法、负数或非有限值视为未知。\nfunc (t *ProviderTemplate) ModelCostMultiplierForModel(modelID string) (float64, bool) {\n\tif t == nil {\n\t\treturn 0, false\n\t}\n\tmultiplier, _, ok := resolvePatternValueFold(modelID, t.ModelCostMultipliers)\n\tif !ok || multiplier < 0 || math.IsNaN(multiplier) || math.IsInf(multiplier, 0) {\n\t\treturn 0, false\n\t}\n\treturn multiplier, true\n}\n\n// ModelQualityPriorityForModel 返回 provider 已确认的同档模型能力优先级。\nfunc (t *ProviderTemplate) ModelQualityPriorityForModel(modelID string) (int, bool) {\n\tif t == nil {\n\t\treturn 0, false\n\t}\n\tpriority, _, ok := resolvePatternValueFold(modelID, t.ModelQualityPriorities)\n\tif !ok || priority <= 0 {\n\t\treturn 0, false\n\t}\n\treturn priority, true\n}\n\n// InferProviderIDFromBaseURL 仅按已知模板候选端点识别 provider。\n// 使用最长路径匹配,避免把仅承载同名模型的其他中转站误判为该渠道。\nfunc InferProviderIDFromBaseURL(baseURL string) (string, bool) {\n\ttarget, err := url.Parse(strings.TrimSuffix(strings.TrimSpace(baseURL), \"#\"))\n\tif err != nil || target.Hostname() == \"\" {\n\t\treturn \"\", false\n\t}\n\ttargetPath := strings.TrimRight(target.EscapedPath(), \"/\")\n\tbestProvider := \"\"\n\tbestPathLen := -1\n\tfor _, tmpl := range builtinProviderTemplates {\n\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\tfor _, candidate := range route.Candidates {\n\t\t\t\tcandidateURL, parseErr := url.Parse(strings.TrimSpace(candidate.BaseURL))\n\t\t\t\tif parseErr != nil || !sameURLServer(target, candidateURL) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tcandidatePath := strings.TrimRight(candidateURL.EscapedPath(), \"/\")\n\t\t\t\tif candidatePath != \"\" && targetPath != candidatePath && !strings.HasPrefix(targetPath, candidatePath+\"/\") {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif len(candidatePath) > bestPathLen {\n\t\t\t\t\tbestProvider = tmpl.ProviderID\n\t\t\t\t\tbestPathLen = len(candidatePath)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn bestProvider, bestProvider != \"\"\n}\n\n// InferProviderIDFromAPIKey 仅识别具有官方唯一格式的 API Key。\n// sk- 等共享格式无法可靠区分 provider,因此不会参与推断。\nfunc InferProviderIDFromAPIKey(apiKey string) (string, bool) {\n\tif zhipuAPIKeyPattern.MatchString(strings.TrimSpace(apiKey)) {\n\t\treturn \"glm\", true\n\t}\n\treturn \"\", false\n}\n\nfunc sameURLServer(left, right *url.URL) bool {\n\tif left == nil || right == nil || !strings.EqualFold(left.Hostname(), right.Hostname()) {\n\t\treturn false\n\t}\n\treturn effectiveURLPort(left) == effectiveURLPort(right)\n}\n\nfunc effectiveURLPort(value *url.URL) string {\n\tif value == nil {\n\t\treturn \"\"\n\t}\n\tif port := value.Port(); port != \"\" {\n\t\treturn port\n\t}\n\tswitch strings.ToLower(value.Scheme) {\n\tcase \"https\":\n\t\treturn \"443\"\n\tcase \"http\":\n\t\treturn \"80\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// AutoAddRoutes 返回 provider 快速添加时需要创建的渠道 route。\nfunc (t *ProviderTemplate) AutoAddRoutes() []ProviderRoute {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tif len(t.Routes) > 0 {\n\t\tout := make([]ProviderRoute, len(t.Routes))\n\t\tcopy(out, t.Routes)\n\t\treturn out\n\t}\n\tif t.ChannelKind == \"\" || t.ServiceType == \"\" {\n\t\treturn nil\n\t}\n\treturn []ProviderRoute{{\n\t\tChannelKind: t.ChannelKind,\n\t\tServiceType: t.ServiceType,\n\t\tCandidates: append([]ProviderCandidate(nil), t.Candidates...),\n\t}}\n}\n\n// SupportsChannelKind 判断 provider 是否可从指定渠道页发起快速添加。\nfunc (t *ProviderTemplate) SupportsChannelKind(kind string) bool {\n\tfor _, route := range t.AutoAddRoutes() {\n\t\tif route.ChannelKind == kind {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// CandidatesForKey 按 API Key 前缀返回默认 route 的候选 baseURL 顺序。\n//\n// 规则(对应用户决策):\n// 1. key 前缀命中某 PlanTag → 该 plan 的候选优先(按 Priority 升序),其余候选追加在后作为回退\n// 2. 前缀不匹配任何规则(或模板无前缀规则)→ 返回全部候选(按 Priority 升序)\n//\n// 返回的候选已按探测顺序排列,调用方依次探测,命中即用。\nfunc (t *ProviderTemplate) CandidatesForKey(apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\treturn t.CandidatesForRouteKey(ProviderRoute{Candidates: t.Candidates}, apiKey)\n}\n\n// CandidatesForRouteKey 按 API Key 前缀返回指定 route 的候选 baseURL 顺序。\nfunc (t *ProviderTemplate) CandidatesForRouteKey(route ProviderRoute, apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tmatchedPlan := \"\"\n\tfor _, rule := range t.KeyPrefixRules {\n\t\tif rule.Prefix != \"\" && strings.HasPrefix(apiKey, rule.Prefix) {\n\t\t\tmatchedPlan = rule.PlanTag\n\t\t\tbreak\n\t\t}\n\t}\n\n\tsortByPriority := func(list []ProviderCandidate) []ProviderCandidate {\n\t\tsorted := make([]ProviderCandidate, len(list))\n\t\tcopy(sorted, list)\n\t\t// 稳定插入排序(候选数量极少,无需 sort 包)\n\t\tfor i := 1; i < len(sorted); i++ {\n\t\t\tfor j := i; j > 0 && sorted[j].Priority < sorted[j-1].Priority; j-- {\n\t\t\t\tsorted[j], sorted[j-1] = sorted[j-1], sorted[j]\n\t\t\t}\n\t\t}\n\t\treturn sorted\n\t}\n\n\tif matchedPlan == \"\" {\n\t\treturn sortByPriority(route.Candidates)\n\t}\n\n\tpreferred := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfallback := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfor _, cand := range route.Candidates {\n\t\tif cand.PlanTag == matchedPlan {\n\t\t\tpreferred = append(preferred, cand)\n\t\t} else {\n\t\t\tfallback = append(fallback, cand)\n\t\t}\n\t}\n\treturn append(sortByPriority(preferred), sortByPriority(fallback)...)\n}\n\nfunc mimoClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc mimoChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc volcenginePlanClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n\nfunc volcenginePlanChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n"}, "48488333f5455eef9b0999cee1eaf5bb63dd23d6": {"t": 1785746058.354471, "n": 25135, "h": "4b8622458f235472", "c": "package config\n\nimport (\n\t\"math\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n)\n\n// ProviderTemplate 描述一个已知 provider 的模板化添加配置。\n//\n// 设计目标:用户只需选择 provider + 输入 API Key,系统按 key 前缀选候选 baseURL,\n// 探测验证可用性后自动创建渠道,无需手填 baseURL / 选协议 / 配兼容开关。\n//\n// baseURL 按上游原生协议区分端点:Claude 请求优先走原生 Claude 入口;\n// Chat / Codex Responses 请求走 OpenAI Chat 兼容入口,由后端做协议转换。\n// ProviderTemplate 只描述来源和端点,不再承载 channel-presets 兼容开关;\n// autoManaged 渠道的模型与能力差异由后端智能调度/ModelResolver 处理。\ntype ProviderTemplate struct {\n\tProviderID string `json:\"providerId\"` // \"mimo\" / \"deepseek\" / ...\n\tAliases []string `json:\"aliases,omitempty\"`\n\tDisplayName string `json:\"displayName\"` // \"小米 MiMo\"\n\tDescription string `json:\"description\"` // key 前缀说明等\n\tChannelKind string `json:\"channelKind\"` // 默认 route 的渠道类型,兼容旧前端\n\tServiceType string `json:\"serviceType\"` // 默认 route 的服务类型,兼容旧前端\n\tOriginType string `json:\"originType\"` // official_api / official_token_plan / relay\n\tOriginTier string `json:\"originTier\"` // first / second\n\tKeyPrefixRules []KeyPrefixRule `json:\"keyPrefixRules\"` // key 前缀 → plan 判别\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 默认 route 的候选 baseURL\n\tRoutes []ProviderRoute `json:\"routes,omitempty\"`\n\t// ModelCostMultipliers 描述同一 provider 套餐内每次模型调用的相对消耗。\n\t// key 支持模型精确 ID 或通配符;值越低越省,只在质量与实测表现相同后参与选优。\n\tModelCostMultipliers map[string]float64 `json:\"modelCostMultipliers,omitempty\"`\n\t// ModelQualityPriorities 描述 provider 已确认的同质量档模型能力顺序,值越高越强。\n\t// 该映射允许不完整;解析器仅在同档候选全部命中时比较,避免未知模型被无依据降权。\n\tModelQualityPriorities map[string]int `json:\"modelQualityPriorities,omitempty\"`\n}\n\n// ProviderRoute 描述同一 provider 在某个 CCX 渠道协议下使用的原生上游入口。\ntype ProviderRoute struct {\n\tChannelKind string `json:\"channelKind\"` // \"messages\" / \"chat\" / \"responses\"\n\tServiceType string `json:\"serviceType\"` // \"claude\" / \"openai\" / \"responses\"\n\tDescription string `json:\"description,omitempty\"` // route 说明,仅展示/诊断\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 该 route 的候选 baseURL\n\t// SupportedModels 限定该 route 端点实际接受的模型,语义与 UpstreamConfig.SupportedModels 一致\n\t// (支持精确匹配、prefix* / *suffix / *contains*,以及 ! 前缀排除)。\n\t//\n\t// 仅在上游同一 baseURL 下不同协议端点的模型支持范围不一致时才需要填写:\n\t// 例如 DeepSeek 的 /v1/responses 只接受 deepseek-v4-flash,而 /v1/chat/completions\n\t// 同时接受 flash 与 pro。留空表示该 route 不额外限制模型。\n\tSupportedModels []string `json:\"supportedModels,omitempty\"`\n}\n\n// KeyPrefixRule 按 API Key 前缀判别 plan 类型。\ntype KeyPrefixRule struct {\n\tPrefix string `json:\"prefix\"` // \"sk-\" / \"tp-\"\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\"\n}\n\n// ProviderCandidate 一个候选 baseURL 及其归属信息。\ntype ProviderCandidate struct {\n\tBaseURL string `json:\"baseUrl\"` // 完整 baseURL,如 https://api.xiaomimimo.com/anthropic\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\" / \"\"\n\tRegion string `json:\"region\"` // \"cn\" / \"sgp\" / \"ams\" / \"global\" / \"\"\n\tPriority int `json:\"priority\"` // 同 plan 内探测优先级(数字越小越先探测)\n}\n\n// builtinProviderTemplates 编译期内置的已知 provider 模板。\n//\n// URL 来源(2026-07 联网核实官方文档):\n// - MiMo: https://mimo.mi.com/docs(按量 sk- / Token Plan tp-,TP 分 cn/sgp/ams 三区域集群)\n// - DeepSeek: https://api-docs.deepseek.com(Anthropic 兼容 /anthropic)\n// - Kimi: https://api.moonshot.ai/anthropic(全球)/ https://api.moonshot.cn/anthropic(中国)\n// - GLM: https://open.bigmodel.cn/api/anthropic(Claude)与 /api/paas/v4(OpenAI)\n// - 火山方舟: https://ark.cn-beijing.volces.com/api/plan(Agent Plan)与 /api/coding(Coding Plan)\n//\n// Claude route 的 baseURL 使用 Anthropic 兼容入口且不带 /v1(claude provider 会自动补 /v1/messages)。\n// Chat/Responses route 使用 OpenAI Chat 兼容入口,由 provider 自动补协议端点。\nvar builtinProviderTemplates = []ProviderTemplate{\n\t{\n\t\tProviderID: \"mimo\",\n\t\tDisplayName: \"小米 MiMo\",\n\t\tDescription: \"sk- 按量付费 / tp- Token Plan 订阅(Claude 走 /anthropic,Chat/Codex/Gemini 走 /v1)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"sk-\", PlanTag: \"payg\"},\n\t\t\t{Prefix: \"tp-\", PlanTag: \"token_plan\"},\n\t\t},\n\t\tCandidates: mimoClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages 原生 Anthropic 兼容入口\",\n\t\t\t\tCandidates: mimoClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"deepseek\",\n\t\tDisplayName: \"DeepSeek\",\n\t\tDescription: \"DeepSeek 官方 API(Claude Messages、OpenAI Chat 与 Responses 三协议原生兼容;Responses 仅支持 deepseek-v4-flash)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: deepseekClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages Anthropic 兼容入口\",\n\t\t\t\tCandidates: deepseekClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: deepseekOpenAICandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"responses\",\n\t\t\t\tDescription: \"OpenAI Responses 原生入口(仅 deepseek-v4-flash;v4-pro 预计 2026-08 初支持)\",\n\t\t\t\tCandidates: deepseekResponsesCandidates(),\n\t\t\t\t// 上游对不支持的模型返回 400 而非忽略,因此在渠道层就限定为正向白名单:\n\t\t\t\t// 未知新模型不会被误放进该端点,v4-pro 上线后在此追加即可。\n\t\t\t\tSupportedModels: []string{\"deepseek-v4-flash\"},\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"volcengine\",\n\t\tAliases: []string{\"volc-ark\"},\n\t\tDisplayName: \"火山方舟 Agent/Coding Plan\",\n\t\tDescription: \"ark- 套餐推理 Key。Agent Plan 支持 AFP 感知路由(详见 volcengine_afp_pricing.go);Coding Plan 计费规则未核验,仅使用通用定价。模型发现需为每个 Key 绑定火山云 Access Key。\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"ark-\", PlanTag: \"personal_plan\"},\n\t\t},\n\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Agent/Coding Plan Claude Messages 入口\",\n\t\t\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Agent/Coding Plan OpenAI Chat Completions 入口\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"kimi\",\n\t\tAliases: []string{\"kimi-code\"},\n\t\tDisplayName: \"Kimi (Moonshot)\",\n\t\tDescription: \"Moonshot Kimi 官方 API(按量与 Coding Plan,自动判别可用入口)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: kimiClaudeCandidates(),\n\t\tRoutes: standardProviderRoutes(\"claude\", kimiClaudeCandidates(), kimiOpenAICandidates()),\n\t},\n\t{\n\t\tProviderID: \"glm\",\n\t\tDisplayName: \"智谱 GLM\",\n\t\tDescription: \"智谱 GLM 官方 API(自动识别 id.secret Key;按量与 Coding Plan)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: glmClaudeCandidates(),\n\t\tRoutes: standardProviderRoutes(\"claude\", glmClaudeCandidates(), glmOpenAICandidates()),\n\t},\n\tcompshareProviderTemplate(),\n\tnewProviderTemplate(\n\t\t\"sensenova\",\n\t\t\"SenseNova\",\n\t\t\"商汤日日新官方 API\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://token.sensenova.cn\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://token.sensenova.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"minimax\",\n\t\t\"MiniMax\",\n\t\t\"MiniMax 官方 API\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.minimaxi.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.minimax.chat/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"dashscope\",\n\t\t\"阿里云 DashScope\",\n\t\t\"阿里云百炼按量、Coding Plan 与 Token Plan\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", dashScopeClaudeCandidates(), dashScopeOpenAICandidates()),\n\t),\n\tnewProviderTemplateWithAliases(\n\t\t\"opencode-zen\",\n\t\t[]string{\"opencode-go\"},\n\t\t\"OpenCode Zen / Go\",\n\t\t\"OpenCode 官方 Zen 按量与 Go 订阅模型网关\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"openai\", openCodeCandidates(), openCodeCandidates()),\n\t),\n\tnewProviderTemplate(\n\t\t\"tencent-lkeap\",\n\t\t\"腾讯云 TokenHub\",\n\t\t\"腾讯云大模型 Token Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/v3\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"qianfan\",\n\t\t\"百度千帆 Coding Plan\",\n\t\t\"百度智能云千帆 Coding Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://qianfan.baidubce.com/anthropic/coding\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://qianfan.baidubce.com/v2/coding#\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"xfyun\",\n\t\t\"讯飞星辰\",\n\t\t\"科大讯飞星辰 MaaS Coding Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\txfYunRoutes(),\n\t),\n\tnewProviderTemplate(\n\t\t\"openrouter\",\n\t\t\"OpenRouter\",\n\t\t\"OpenRouter 多模型聚合平台\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://openrouter.ai/api\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://openrouter.ai/api/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"modelscope\",\n\t\t\"ModelScope 魔搭\",\n\t\t\"阿里 ModelScope 官方推理服务\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api-inference.modelscope.cn\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api-inference.modelscope.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"originrouter\",\n\t\t\"极易云 OriginRouter\",\n\t\t\"极易云多模型 API 转发平台\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.easytransnote.com/coding\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.easytransnote.com/coding/v1\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n}\n\nvar zhipuAPIKeyPattern = regexp.MustCompile(`^[A-Za-z0-9]{20,}\\.[A-Za-z0-9]{10,}$`)\n\nfunc standardProviderRoutes(messagesServiceType string, messagesCandidates, openAICandidates []ProviderCandidate) []ProviderRoute {\n\tmessagesDescription := \"Claude Messages 原生 Anthropic 兼容入口\"\n\tif messagesServiceType == \"openai\" {\n\t\tmessagesDescription = \"Claude Messages 请求转换到 OpenAI Chat Completions\"\n\t}\n\treturn []ProviderRoute{\n\t\t{\n\t\t\tChannelKind: \"messages\",\n\t\t\tServiceType: messagesServiceType,\n\t\t\tDescription: messagesDescription,\n\t\t\tCandidates: messagesCandidates,\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"chat\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\tCandidates: openAICandidates,\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"responses\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\tCandidates: openAICandidates,\n\t\t},\n\t}\n}\n\nfunc newProviderTemplate(providerID, displayName, description, originType, originTier string, routes []ProviderRoute) ProviderTemplate {\n\treturn newProviderTemplateWithAliases(providerID, nil, displayName, description, originType, originTier, routes)\n}\n\nfunc newProviderTemplateWithAliases(providerID string, aliases []string, displayName, description, originType, originTier string, routes []ProviderRoute) ProviderTemplate {\n\ttmpl := ProviderTemplate{\n\t\tProviderID: providerID,\n\t\tAliases: aliases,\n\t\tDisplayName: displayName,\n\t\tDescription: description,\n\t\tOriginType: originType,\n\t\tOriginTier: originTier,\n\t\tRoutes: routes,\n\t}\n\tif len(routes) > 0 {\n\t\ttmpl.ChannelKind = routes[0].ChannelKind\n\t\ttmpl.ServiceType = routes[0].ServiceType\n\t\ttmpl.Candidates = routes[0].Candidates\n\t}\n\treturn tmpl\n}\n\nfunc compshareProviderTemplate() ProviderTemplate {\n\ttmpl := newProviderTemplate(\n\t\t\"compshare\",\n\t\t\"优云智算套餐\",\n\t\t\"UCloud 优云智算模型套餐与聚合服务\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://cp.compshare.cn\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://cp.compshare.cn/v1\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t)\n\t// 优云智算控制台的套餐消耗次数;glm-5.2 当前为限时倍率。\n\ttmpl.ModelCostMultipliers = map[string]float64{\n\t\t\"glm-5.2\": 2,\n\t\t\"glm-5.1\": 6,\n\t\t\"MiniMax-M2.7\": 2,\n\t\t\"kimi-k2.6\": 5,\n\t\t\"deepseek-ai/DeepSeek-V3.2\": 1,\n\t\t\"deepseek-v4-flash\": 1,\n\t}\n\ttmpl.ModelQualityPriorities = map[string]int{\n\t\t\"glm-5.1\": 2,\n\t\t\"kimi-k2.6\": 1,\n\t}\n\treturn tmpl\n}\n\nfunc deepseekClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com/anthropic\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\nfunc deepseekOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\n// deepseekResponsesCandidates DeepSeek 原生 Responses 入口。\n// 官方 base_url 为 https://api.deepseek.com,端点为标准 /v1/responses。\nfunc deepseekResponsesCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com/v1/responses\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\nfunc glmClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t}\n}\n\nfunc glmOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t// # 表示该路径已经是版本根,调用方不得再自动补 /v1。\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/paas/v4#\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/coding/paas/v4#\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n\nfunc kimiClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.moonshot.cn/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://api.moonshot.ai/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t\t{BaseURL: \"https://api.kimi.com/coding\", PlanTag: \"coding_plan\", Region: \"global\", Priority: 2},\n\t}\n}\n\nfunc kimiOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.moonshot.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://api.moonshot.ai/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t\t{BaseURL: \"https://api.kimi.com/coding/v1\", PlanTag: \"coding_plan\", Region: \"global\", Priority: 2},\n\t}\n}\n\nfunc dashScopeClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://dashscope.aliyuncs.com/apps/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://coding.dashscope.aliyuncs.com/apps/anthropic\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 2},\n\t}\n}\n\nfunc dashScopeOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://dashscope.aliyuncs.com/compatible-mode/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://coding.dashscope.aliyuncs.com/v1\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 2},\n\t}\n}\n\nfunc openCodeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://opencode.ai/zen/go/v1\", PlanTag: \"subscription\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://opencode.ai/zen/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t}\n}\n\nfunc xfYunRoutes() []ProviderRoute {\n\treturn []ProviderRoute{\n\t\t{\n\t\t\tChannelKind: \"messages\",\n\t\t\tServiceType: \"claude\",\n\t\t\tDescription: \"Claude Messages Coding Plan 入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/anthropic\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"chat\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"OpenAI Chat Completions Coding Plan 入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v2\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"responses\",\n\t\t\tServiceType: \"responses\",\n\t\t\tDescription: \"OpenAI Responses Coding Plan 原生入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v1/responses\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t}\n}\n\n// ListProviderTemplates 返回所有内置 provider 模板。\nfunc ListProviderTemplates() []ProviderTemplate {\n\tout := make([]ProviderTemplate, len(builtinProviderTemplates))\n\tcopy(out, builtinProviderTemplates)\n\treturn out\n}\n\n// GetProviderTemplate 按 providerId 查找模板,未找到返回 (nil, false)。\nfunc GetProviderTemplate(providerID string) (*ProviderTemplate, bool) {\n\tproviderID = strings.ToLower(strings.TrimSpace(providerID))\n\tfor i := range builtinProviderTemplates {\n\t\ttmpl := &builtinProviderTemplates[i]\n\t\tif strings.EqualFold(tmpl.ProviderID, providerID) {\n\t\t\treturn tmpl, true\n\t\t}\n\t\tfor _, alias := range tmpl.Aliases {\n\t\t\tif strings.EqualFold(alias, providerID) {\n\t\t\t\treturn tmpl, true\n\t\t\t}\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ModelCostMultiplierForModel 返回 provider 套餐内指定模型的相对消耗倍率。\n// 精确 ID 优先,随后按最长通配符匹配;非法、负数或非有限值视为未知。\nfunc (t *ProviderTemplate) ModelCostMultiplierForModel(modelID string) (float64, bool) {\n\tif t == nil {\n\t\treturn 0, false\n\t}\n\tmultiplier, _, ok := resolvePatternValueFold(modelID, t.ModelCostMultipliers)\n\tif !ok || multiplier < 0 || math.IsNaN(multiplier) || math.IsInf(multiplier, 0) {\n\t\treturn 0, false\n\t}\n\treturn multiplier, true\n}\n\n// ModelQualityPriorityForModel 返回 provider 已确认的同档模型能力优先级。\nfunc (t *ProviderTemplate) ModelQualityPriorityForModel(modelID string) (int, bool) {\n\tif t == nil {\n\t\treturn 0, false\n\t}\n\tpriority, _, ok := resolvePatternValueFold(modelID, t.ModelQualityPriorities)\n\tif !ok || priority <= 0 {\n\t\treturn 0, false\n\t}\n\treturn priority, true\n}\n\n// InferProviderIDFromBaseURL 仅按已知模板候选端点识别 provider。\n// 使用最长路径匹配,避免把仅承载同名模型的其他中转站误判为该渠道。\nfunc InferProviderIDFromBaseURL(baseURL string) (string, bool) {\n\ttarget, err := url.Parse(strings.TrimSuffix(strings.TrimSpace(baseURL), \"#\"))\n\tif err != nil || target.Hostname() == \"\" {\n\t\treturn \"\", false\n\t}\n\ttargetPath := strings.TrimRight(target.EscapedPath(), \"/\")\n\tbestProvider := \"\"\n\tbestPathLen := -1\n\tfor _, tmpl := range builtinProviderTemplates {\n\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\tfor _, candidate := range route.Candidates {\n\t\t\t\tcandidateURL, parseErr := url.Parse(strings.TrimSpace(candidate.BaseURL))\n\t\t\t\tif parseErr != nil || !sameURLServer(target, candidateURL) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tcandidatePath := strings.TrimRight(candidateURL.EscapedPath(), \"/\")\n\t\t\t\tif candidatePath != \"\" && targetPath != candidatePath && !strings.HasPrefix(targetPath, candidatePath+\"/\") {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif len(candidatePath) > bestPathLen {\n\t\t\t\t\tbestProvider = tmpl.ProviderID\n\t\t\t\t\tbestPathLen = len(candidatePath)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn bestProvider, bestProvider != \"\"\n}\n\n// InferProviderIDFromAPIKey 仅识别具有官方唯一格式的 API Key。\n// sk- 等共享格式无法可靠区分 provider,因此不会参与推断。\nfunc InferProviderIDFromAPIKey(apiKey string) (string, bool) {\n\tif zhipuAPIKeyPattern.MatchString(strings.TrimSpace(apiKey)) {\n\t\treturn \"glm\", true\n\t}\n\treturn \"\", false\n}\n\nfunc sameURLServer(left, right *url.URL) bool {\n\tif left == nil || right == nil || !strings.EqualFold(left.Hostname(), right.Hostname()) {\n\t\treturn false\n\t}\n\treturn effectiveURLPort(left) == effectiveURLPort(right)\n}\n\nfunc effectiveURLPort(value *url.URL) string {\n\tif value == nil {\n\t\treturn \"\"\n\t}\n\tif port := value.Port(); port != \"\" {\n\t\treturn port\n\t}\n\tswitch strings.ToLower(value.Scheme) {\n\tcase \"https\":\n\t\treturn \"443\"\n\tcase \"http\":\n\t\treturn \"80\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// AutoAddRoutes 返回 provider 快速添加时需要创建的渠道 route。\nfunc (t *ProviderTemplate) AutoAddRoutes() []ProviderRoute {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tif len(t.Routes) > 0 {\n\t\tout := make([]ProviderRoute, len(t.Routes))\n\t\tcopy(out, t.Routes)\n\t\treturn out\n\t}\n\tif t.ChannelKind == \"\" || t.ServiceType == \"\" {\n\t\treturn nil\n\t}\n\treturn []ProviderRoute{{\n\t\tChannelKind: t.ChannelKind,\n\t\tServiceType: t.ServiceType,\n\t\tCandidates: append([]ProviderCandidate(nil), t.Candidates...),\n\t}}\n}\n\n// SupportsChannelKind 判断 provider 是否可从指定渠道页发起快速添加。\nfunc (t *ProviderTemplate) SupportsChannelKind(kind string) bool {\n\tfor _, route := range t.AutoAddRoutes() {\n\t\tif route.ChannelKind == kind {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// CandidatesForKey 按 API Key 前缀返回默认 route 的候选 baseURL 顺序。\n//\n// 规则(对应用户决策):\n// 1. key 前缀命中某 PlanTag → 该 plan 的候选优先(按 Priority 升序),其余候选追加在后作为回退\n// 2. 前缀不匹配任何规则(或模板无前缀规则)→ 返回全部候选(按 Priority 升序)\n//\n// 返回的候选已按探测顺序排列,调用方依次探测,命中即用。\nfunc (t *ProviderTemplate) CandidatesForKey(apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\treturn t.CandidatesForRouteKey(ProviderRoute{Candidates: t.Candidates}, apiKey)\n}\n\n// CandidatesForRouteKey 按 API Key 前缀返回指定 route 的候选 baseURL 顺序。\nfunc (t *ProviderTemplate) CandidatesForRouteKey(route ProviderRoute, apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tmatchedPlan := \"\"\n\tfor _, rule := range t.KeyPrefixRules {\n\t\tif rule.Prefix != \"\" && strings.HasPrefix(apiKey, rule.Prefix) {\n\t\t\tmatchedPlan = rule.PlanTag\n\t\t\tbreak\n\t\t}\n\t}\n\n\tsortByPriority := func(list []ProviderCandidate) []ProviderCandidate {\n\t\tsorted := make([]ProviderCandidate, len(list))\n\t\tcopy(sorted, list)\n\t\t// 稳定插入排序(候选数量极少,无需 sort 包)\n\t\tfor i := 1; i < len(sorted); i++ {\n\t\t\tfor j := i; j > 0 && sorted[j].Priority < sorted[j-1].Priority; j-- {\n\t\t\t\tsorted[j], sorted[j-1] = sorted[j-1], sorted[j]\n\t\t\t}\n\t\t}\n\t\treturn sorted\n\t}\n\n\tif matchedPlan == \"\" {\n\t\treturn sortByPriority(route.Candidates)\n\t}\n\n\tpreferred := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfallback := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfor _, cand := range route.Candidates {\n\t\tif cand.PlanTag == matchedPlan {\n\t\t\tpreferred = append(preferred, cand)\n\t\t} else {\n\t\t\tfallback = append(fallback, cand)\n\t\t}\n\t}\n\treturn append(sortByPriority(preferred), sortByPriority(fallback)...)\n}\n\nfunc mimoClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc mimoChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc volcenginePlanClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n\nfunc volcenginePlanChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n"}, "f6977b2d05d47b726706c52e0ff5e873888ad2a1": {"t": 1785746653.0336196, "n": 25135, "h": "4b8622458f235472", "c": "package config\n\nimport (\n\t\"math\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n)\n\n// ProviderTemplate 描述一个已知 provider 的模板化添加配置。\n//\n// 设计目标:用户只需选择 provider + 输入 API Key,系统按 key 前缀选候选 baseURL,\n// 探测验证可用性后自动创建渠道,无需手填 baseURL / 选协议 / 配兼容开关。\n//\n// baseURL 按上游原生协议区分端点:Claude 请求优先走原生 Claude 入口;\n// Chat / Codex Responses 请求走 OpenAI Chat 兼容入口,由后端做协议转换。\n// ProviderTemplate 只描述来源和端点,不再承载 channel-presets 兼容开关;\n// autoManaged 渠道的模型与能力差异由后端智能调度/ModelResolver 处理。\ntype ProviderTemplate struct {\n\tProviderID string `json:\"providerId\"` // \"mimo\" / \"deepseek\" / ...\n\tAliases []string `json:\"aliases,omitempty\"`\n\tDisplayName string `json:\"displayName\"` // \"小米 MiMo\"\n\tDescription string `json:\"description\"` // key 前缀说明等\n\tChannelKind string `json:\"channelKind\"` // 默认 route 的渠道类型,兼容旧前端\n\tServiceType string `json:\"serviceType\"` // 默认 route 的服务类型,兼容旧前端\n\tOriginType string `json:\"originType\"` // official_api / official_token_plan / relay\n\tOriginTier string `json:\"originTier\"` // first / second\n\tKeyPrefixRules []KeyPrefixRule `json:\"keyPrefixRules\"` // key 前缀 → plan 判别\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 默认 route 的候选 baseURL\n\tRoutes []ProviderRoute `json:\"routes,omitempty\"`\n\t// ModelCostMultipliers 描述同一 provider 套餐内每次模型调用的相对消耗。\n\t// key 支持模型精确 ID 或通配符;值越低越省,只在质量与实测表现相同后参与选优。\n\tModelCostMultipliers map[string]float64 `json:\"modelCostMultipliers,omitempty\"`\n\t// ModelQualityPriorities 描述 provider 已确认的同质量档模型能力顺序,值越高越强。\n\t// 该映射允许不完整;解析器仅在同档候选全部命中时比较,避免未知模型被无依据降权。\n\tModelQualityPriorities map[string]int `json:\"modelQualityPriorities,omitempty\"`\n}\n\n// ProviderRoute 描述同一 provider 在某个 CCX 渠道协议下使用的原生上游入口。\ntype ProviderRoute struct {\n\tChannelKind string `json:\"channelKind\"` // \"messages\" / \"chat\" / \"responses\"\n\tServiceType string `json:\"serviceType\"` // \"claude\" / \"openai\" / \"responses\"\n\tDescription string `json:\"description,omitempty\"` // route 说明,仅展示/诊断\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 该 route 的候选 baseURL\n\t// SupportedModels 限定该 route 端点实际接受的模型,语义与 UpstreamConfig.SupportedModels 一致\n\t// (支持精确匹配、prefix* / *suffix / *contains*,以及 ! 前缀排除)。\n\t//\n\t// 仅在上游同一 baseURL 下不同协议端点的模型支持范围不一致时才需要填写:\n\t// 例如 DeepSeek 的 /v1/responses 只接受 deepseek-v4-flash,而 /v1/chat/completions\n\t// 同时接受 flash 与 pro。留空表示该 route 不额外限制模型。\n\tSupportedModels []string `json:\"supportedModels,omitempty\"`\n}\n\n// KeyPrefixRule 按 API Key 前缀判别 plan 类型。\ntype KeyPrefixRule struct {\n\tPrefix string `json:\"prefix\"` // \"sk-\" / \"tp-\"\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\"\n}\n\n// ProviderCandidate 一个候选 baseURL 及其归属信息。\ntype ProviderCandidate struct {\n\tBaseURL string `json:\"baseUrl\"` // 完整 baseURL,如 https://api.xiaomimimo.com/anthropic\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\" / \"\"\n\tRegion string `json:\"region\"` // \"cn\" / \"sgp\" / \"ams\" / \"global\" / \"\"\n\tPriority int `json:\"priority\"` // 同 plan 内探测优先级(数字越小越先探测)\n}\n\n// builtinProviderTemplates 编译期内置的已知 provider 模板。\n//\n// URL 来源(2026-07 联网核实官方文档):\n// - MiMo: https://mimo.mi.com/docs(按量 sk- / Token Plan tp-,TP 分 cn/sgp/ams 三区域集群)\n// - DeepSeek: https://api-docs.deepseek.com(Anthropic 兼容 /anthropic)\n// - Kimi: https://api.moonshot.ai/anthropic(全球)/ https://api.moonshot.cn/anthropic(中国)\n// - GLM: https://open.bigmodel.cn/api/anthropic(Claude)与 /api/paas/v4(OpenAI)\n// - 火山方舟: https://ark.cn-beijing.volces.com/api/plan(Agent Plan)与 /api/coding(Coding Plan)\n//\n// Claude route 的 baseURL 使用 Anthropic 兼容入口且不带 /v1(claude provider 会自动补 /v1/messages)。\n// Chat/Responses route 使用 OpenAI Chat 兼容入口,由 provider 自动补协议端点。\nvar builtinProviderTemplates = []ProviderTemplate{\n\t{\n\t\tProviderID: \"mimo\",\n\t\tDisplayName: \"小米 MiMo\",\n\t\tDescription: \"sk- 按量付费 / tp- Token Plan 订阅(Claude 走 /anthropic,Chat/Codex/Gemini 走 /v1)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"sk-\", PlanTag: \"payg\"},\n\t\t\t{Prefix: \"tp-\", PlanTag: \"token_plan\"},\n\t\t},\n\t\tCandidates: mimoClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages 原生 Anthropic 兼容入口\",\n\t\t\t\tCandidates: mimoClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"deepseek\",\n\t\tDisplayName: \"DeepSeek\",\n\t\tDescription: \"DeepSeek 官方 API(Claude Messages、OpenAI Chat 与 Responses 三协议原生兼容;Responses 仅支持 deepseek-v4-flash)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: deepseekClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages Anthropic 兼容入口\",\n\t\t\t\tCandidates: deepseekClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: deepseekOpenAICandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"responses\",\n\t\t\t\tDescription: \"OpenAI Responses 原生入口(仅 deepseek-v4-flash;v4-pro 预计 2026-08 初支持)\",\n\t\t\t\tCandidates: deepseekResponsesCandidates(),\n\t\t\t\t// 上游对不支持的模型返回 400 而非忽略,因此在渠道层就限定为正向白名单:\n\t\t\t\t// 未知新模型不会被误放进该端点,v4-pro 上线后在此追加即可。\n\t\t\t\tSupportedModels: []string{\"deepseek-v4-flash\"},\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"volcengine\",\n\t\tAliases: []string{\"volc-ark\"},\n\t\tDisplayName: \"火山方舟 Agent/Coding Plan\",\n\t\tDescription: \"ark- 套餐推理 Key。Agent Plan 支持 AFP 感知路由(详见 volcengine_afp_pricing.go);Coding Plan 计费规则未核验,仅使用通用定价。模型发现需为每个 Key 绑定火山云 Access Key。\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"ark-\", PlanTag: \"personal_plan\"},\n\t\t},\n\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Agent/Coding Plan Claude Messages 入口\",\n\t\t\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Agent/Coding Plan OpenAI Chat Completions 入口\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"kimi\",\n\t\tAliases: []string{\"kimi-code\"},\n\t\tDisplayName: \"Kimi (Moonshot)\",\n\t\tDescription: \"Moonshot Kimi 官方 API(按量与 Coding Plan,自动判别可用入口)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: kimiClaudeCandidates(),\n\t\tRoutes: standardProviderRoutes(\"claude\", kimiClaudeCandidates(), kimiOpenAICandidates()),\n\t},\n\t{\n\t\tProviderID: \"glm\",\n\t\tDisplayName: \"智谱 GLM\",\n\t\tDescription: \"智谱 GLM 官方 API(自动识别 id.secret Key;按量与 Coding Plan)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: glmClaudeCandidates(),\n\t\tRoutes: standardProviderRoutes(\"claude\", glmClaudeCandidates(), glmOpenAICandidates()),\n\t},\n\tcompshareProviderTemplate(),\n\tnewProviderTemplate(\n\t\t\"sensenova\",\n\t\t\"SenseNova\",\n\t\t\"商汤日日新官方 API\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://token.sensenova.cn\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://token.sensenova.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"minimax\",\n\t\t\"MiniMax\",\n\t\t\"MiniMax 官方 API\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.minimaxi.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.minimax.chat/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"dashscope\",\n\t\t\"阿里云 DashScope\",\n\t\t\"阿里云百炼按量、Coding Plan 与 Token Plan\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", dashScopeClaudeCandidates(), dashScopeOpenAICandidates()),\n\t),\n\tnewProviderTemplateWithAliases(\n\t\t\"opencode-zen\",\n\t\t[]string{\"opencode-go\"},\n\t\t\"OpenCode Zen / Go\",\n\t\t\"OpenCode 官方 Zen 按量与 Go 订阅模型网关\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"openai\", openCodeCandidates(), openCodeCandidates()),\n\t),\n\tnewProviderTemplate(\n\t\t\"tencent-lkeap\",\n\t\t\"腾讯云 TokenHub\",\n\t\t\"腾讯云大模型 Token Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/v3\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"qianfan\",\n\t\t\"百度千帆 Coding Plan\",\n\t\t\"百度智能云千帆 Coding Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://qianfan.baidubce.com/anthropic/coding\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://qianfan.baidubce.com/v2/coding#\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"xfyun\",\n\t\t\"讯飞星辰\",\n\t\t\"科大讯飞星辰 MaaS Coding Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\txfYunRoutes(),\n\t),\n\tnewProviderTemplate(\n\t\t\"openrouter\",\n\t\t\"OpenRouter\",\n\t\t\"OpenRouter 多模型聚合平台\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://openrouter.ai/api\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://openrouter.ai/api/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"modelscope\",\n\t\t\"ModelScope 魔搭\",\n\t\t\"阿里 ModelScope 官方推理服务\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api-inference.modelscope.cn\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api-inference.modelscope.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"originrouter\",\n\t\t\"极易云 OriginRouter\",\n\t\t\"极易云多模型 API 转发平台\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.easytransnote.com/coding\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.easytransnote.com/coding/v1\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n}\n\nvar zhipuAPIKeyPattern = regexp.MustCompile(`^[A-Za-z0-9]{20,}\\.[A-Za-z0-9]{10,}$`)\n\nfunc standardProviderRoutes(messagesServiceType string, messagesCandidates, openAICandidates []ProviderCandidate) []ProviderRoute {\n\tmessagesDescription := \"Claude Messages 原生 Anthropic 兼容入口\"\n\tif messagesServiceType == \"openai\" {\n\t\tmessagesDescription = \"Claude Messages 请求转换到 OpenAI Chat Completions\"\n\t}\n\treturn []ProviderRoute{\n\t\t{\n\t\t\tChannelKind: \"messages\",\n\t\t\tServiceType: messagesServiceType,\n\t\t\tDescription: messagesDescription,\n\t\t\tCandidates: messagesCandidates,\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"chat\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\tCandidates: openAICandidates,\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"responses\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\tCandidates: openAICandidates,\n\t\t},\n\t}\n}\n\nfunc newProviderTemplate(providerID, displayName, description, originType, originTier string, routes []ProviderRoute) ProviderTemplate {\n\treturn newProviderTemplateWithAliases(providerID, nil, displayName, description, originType, originTier, routes)\n}\n\nfunc newProviderTemplateWithAliases(providerID string, aliases []string, displayName, description, originType, originTier string, routes []ProviderRoute) ProviderTemplate {\n\ttmpl := ProviderTemplate{\n\t\tProviderID: providerID,\n\t\tAliases: aliases,\n\t\tDisplayName: displayName,\n\t\tDescription: description,\n\t\tOriginType: originType,\n\t\tOriginTier: originTier,\n\t\tRoutes: routes,\n\t}\n\tif len(routes) > 0 {\n\t\ttmpl.ChannelKind = routes[0].ChannelKind\n\t\ttmpl.ServiceType = routes[0].ServiceType\n\t\ttmpl.Candidates = routes[0].Candidates\n\t}\n\treturn tmpl\n}\n\nfunc compshareProviderTemplate() ProviderTemplate {\n\ttmpl := newProviderTemplate(\n\t\t\"compshare\",\n\t\t\"优云智算套餐\",\n\t\t\"UCloud 优云智算模型套餐与聚合服务\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://cp.compshare.cn\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://cp.compshare.cn/v1\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t)\n\t// 优云智算控制台的套餐消耗次数;glm-5.2 当前为限时倍率。\n\ttmpl.ModelCostMultipliers = map[string]float64{\n\t\t\"glm-5.2\": 2,\n\t\t\"glm-5.1\": 6,\n\t\t\"MiniMax-M2.7\": 2,\n\t\t\"kimi-k2.6\": 5,\n\t\t\"deepseek-ai/DeepSeek-V3.2\": 1,\n\t\t\"deepseek-v4-flash\": 1,\n\t}\n\ttmpl.ModelQualityPriorities = map[string]int{\n\t\t\"glm-5.1\": 2,\n\t\t\"kimi-k2.6\": 1,\n\t}\n\treturn tmpl\n}\n\nfunc deepseekClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com/anthropic\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\nfunc deepseekOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\n// deepseekResponsesCandidates DeepSeek 原生 Responses 入口。\n// 官方 base_url 为 https://api.deepseek.com,端点为标准 /v1/responses。\nfunc deepseekResponsesCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com/v1/responses\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\nfunc glmClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t}\n}\n\nfunc glmOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t// # 表示该路径已经是版本根,调用方不得再自动补 /v1。\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/paas/v4#\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/coding/paas/v4#\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n\nfunc kimiClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.moonshot.cn/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://api.moonshot.ai/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t\t{BaseURL: \"https://api.kimi.com/coding\", PlanTag: \"coding_plan\", Region: \"global\", Priority: 2},\n\t}\n}\n\nfunc kimiOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.moonshot.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://api.moonshot.ai/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t\t{BaseURL: \"https://api.kimi.com/coding/v1\", PlanTag: \"coding_plan\", Region: \"global\", Priority: 2},\n\t}\n}\n\nfunc dashScopeClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://dashscope.aliyuncs.com/apps/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://coding.dashscope.aliyuncs.com/apps/anthropic\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 2},\n\t}\n}\n\nfunc dashScopeOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://dashscope.aliyuncs.com/compatible-mode/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://coding.dashscope.aliyuncs.com/v1\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 2},\n\t}\n}\n\nfunc openCodeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://opencode.ai/zen/go/v1\", PlanTag: \"subscription\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://opencode.ai/zen/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t}\n}\n\nfunc xfYunRoutes() []ProviderRoute {\n\treturn []ProviderRoute{\n\t\t{\n\t\t\tChannelKind: \"messages\",\n\t\t\tServiceType: \"claude\",\n\t\t\tDescription: \"Claude Messages Coding Plan 入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/anthropic\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"chat\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"OpenAI Chat Completions Coding Plan 入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v2\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"responses\",\n\t\t\tServiceType: \"responses\",\n\t\t\tDescription: \"OpenAI Responses Coding Plan 原生入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v1/responses\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t}\n}\n\n// ListProviderTemplates 返回所有内置 provider 模板。\nfunc ListProviderTemplates() []ProviderTemplate {\n\tout := make([]ProviderTemplate, len(builtinProviderTemplates))\n\tcopy(out, builtinProviderTemplates)\n\treturn out\n}\n\n// GetProviderTemplate 按 providerId 查找模板,未找到返回 (nil, false)。\nfunc GetProviderTemplate(providerID string) (*ProviderTemplate, bool) {\n\tproviderID = strings.ToLower(strings.TrimSpace(providerID))\n\tfor i := range builtinProviderTemplates {\n\t\ttmpl := &builtinProviderTemplates[i]\n\t\tif strings.EqualFold(tmpl.ProviderID, providerID) {\n\t\t\treturn tmpl, true\n\t\t}\n\t\tfor _, alias := range tmpl.Aliases {\n\t\t\tif strings.EqualFold(alias, providerID) {\n\t\t\t\treturn tmpl, true\n\t\t\t}\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ModelCostMultiplierForModel 返回 provider 套餐内指定模型的相对消耗倍率。\n// 精确 ID 优先,随后按最长通配符匹配;非法、负数或非有限值视为未知。\nfunc (t *ProviderTemplate) ModelCostMultiplierForModel(modelID string) (float64, bool) {\n\tif t == nil {\n\t\treturn 0, false\n\t}\n\tmultiplier, _, ok := resolvePatternValueFold(modelID, t.ModelCostMultipliers)\n\tif !ok || multiplier < 0 || math.IsNaN(multiplier) || math.IsInf(multiplier, 0) {\n\t\treturn 0, false\n\t}\n\treturn multiplier, true\n}\n\n// ModelQualityPriorityForModel 返回 provider 已确认的同档模型能力优先级。\nfunc (t *ProviderTemplate) ModelQualityPriorityForModel(modelID string) (int, bool) {\n\tif t == nil {\n\t\treturn 0, false\n\t}\n\tpriority, _, ok := resolvePatternValueFold(modelID, t.ModelQualityPriorities)\n\tif !ok || priority <= 0 {\n\t\treturn 0, false\n\t}\n\treturn priority, true\n}\n\n// InferProviderIDFromBaseURL 仅按已知模板候选端点识别 provider。\n// 使用最长路径匹配,避免把仅承载同名模型的其他中转站误判为该渠道。\nfunc InferProviderIDFromBaseURL(baseURL string) (string, bool) {\n\ttarget, err := url.Parse(strings.TrimSuffix(strings.TrimSpace(baseURL), \"#\"))\n\tif err != nil || target.Hostname() == \"\" {\n\t\treturn \"\", false\n\t}\n\ttargetPath := strings.TrimRight(target.EscapedPath(), \"/\")\n\tbestProvider := \"\"\n\tbestPathLen := -1\n\tfor _, tmpl := range builtinProviderTemplates {\n\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\tfor _, candidate := range route.Candidates {\n\t\t\t\tcandidateURL, parseErr := url.Parse(strings.TrimSpace(candidate.BaseURL))\n\t\t\t\tif parseErr != nil || !sameURLServer(target, candidateURL) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tcandidatePath := strings.TrimRight(candidateURL.EscapedPath(), \"/\")\n\t\t\t\tif candidatePath != \"\" && targetPath != candidatePath && !strings.HasPrefix(targetPath, candidatePath+\"/\") {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif len(candidatePath) > bestPathLen {\n\t\t\t\t\tbestProvider = tmpl.ProviderID\n\t\t\t\t\tbestPathLen = len(candidatePath)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn bestProvider, bestProvider != \"\"\n}\n\n// InferProviderIDFromAPIKey 仅识别具有官方唯一格式的 API Key。\n// sk- 等共享格式无法可靠区分 provider,因此不会参与推断。\nfunc InferProviderIDFromAPIKey(apiKey string) (string, bool) {\n\tif zhipuAPIKeyPattern.MatchString(strings.TrimSpace(apiKey)) {\n\t\treturn \"glm\", true\n\t}\n\treturn \"\", false\n}\n\nfunc sameURLServer(left, right *url.URL) bool {\n\tif left == nil || right == nil || !strings.EqualFold(left.Hostname(), right.Hostname()) {\n\t\treturn false\n\t}\n\treturn effectiveURLPort(left) == effectiveURLPort(right)\n}\n\nfunc effectiveURLPort(value *url.URL) string {\n\tif value == nil {\n\t\treturn \"\"\n\t}\n\tif port := value.Port(); port != \"\" {\n\t\treturn port\n\t}\n\tswitch strings.ToLower(value.Scheme) {\n\tcase \"https\":\n\t\treturn \"443\"\n\tcase \"http\":\n\t\treturn \"80\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// AutoAddRoutes 返回 provider 快速添加时需要创建的渠道 route。\nfunc (t *ProviderTemplate) AutoAddRoutes() []ProviderRoute {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tif len(t.Routes) > 0 {\n\t\tout := make([]ProviderRoute, len(t.Routes))\n\t\tcopy(out, t.Routes)\n\t\treturn out\n\t}\n\tif t.ChannelKind == \"\" || t.ServiceType == \"\" {\n\t\treturn nil\n\t}\n\treturn []ProviderRoute{{\n\t\tChannelKind: t.ChannelKind,\n\t\tServiceType: t.ServiceType,\n\t\tCandidates: append([]ProviderCandidate(nil), t.Candidates...),\n\t}}\n}\n\n// SupportsChannelKind 判断 provider 是否可从指定渠道页发起快速添加。\nfunc (t *ProviderTemplate) SupportsChannelKind(kind string) bool {\n\tfor _, route := range t.AutoAddRoutes() {\n\t\tif route.ChannelKind == kind {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// CandidatesForKey 按 API Key 前缀返回默认 route 的候选 baseURL 顺序。\n//\n// 规则(对应用户决策):\n// 1. key 前缀命中某 PlanTag → 该 plan 的候选优先(按 Priority 升序),其余候选追加在后作为回退\n// 2. 前缀不匹配任何规则(或模板无前缀规则)→ 返回全部候选(按 Priority 升序)\n//\n// 返回的候选已按探测顺序排列,调用方依次探测,命中即用。\nfunc (t *ProviderTemplate) CandidatesForKey(apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\treturn t.CandidatesForRouteKey(ProviderRoute{Candidates: t.Candidates}, apiKey)\n}\n\n// CandidatesForRouteKey 按 API Key 前缀返回指定 route 的候选 baseURL 顺序。\nfunc (t *ProviderTemplate) CandidatesForRouteKey(route ProviderRoute, apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tmatchedPlan := \"\"\n\tfor _, rule := range t.KeyPrefixRules {\n\t\tif rule.Prefix != \"\" && strings.HasPrefix(apiKey, rule.Prefix) {\n\t\t\tmatchedPlan = rule.PlanTag\n\t\t\tbreak\n\t\t}\n\t}\n\n\tsortByPriority := func(list []ProviderCandidate) []ProviderCandidate {\n\t\tsorted := make([]ProviderCandidate, len(list))\n\t\tcopy(sorted, list)\n\t\t// 稳定插入排序(候选数量极少,无需 sort 包)\n\t\tfor i := 1; i < len(sorted); i++ {\n\t\t\tfor j := i; j > 0 && sorted[j].Priority < sorted[j-1].Priority; j-- {\n\t\t\t\tsorted[j], sorted[j-1] = sorted[j-1], sorted[j]\n\t\t\t}\n\t\t}\n\t\treturn sorted\n\t}\n\n\tif matchedPlan == \"\" {\n\t\treturn sortByPriority(route.Candidates)\n\t}\n\n\tpreferred := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfallback := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfor _, cand := range route.Candidates {\n\t\tif cand.PlanTag == matchedPlan {\n\t\t\tpreferred = append(preferred, cand)\n\t\t} else {\n\t\t\tfallback = append(fallback, cand)\n\t\t}\n\t}\n\treturn append(sortByPriority(preferred), sortByPriority(fallback)...)\n}\n\nfunc mimoClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc mimoChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc volcenginePlanClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n\nfunc volcenginePlanChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n"}, "59a3dce74a29648a68d5d56be731f06fd42e4215": {"t": 1785759312.3838806, "n": 25135, "h": "4b8622458f235472", "c": "package config\n\nimport (\n\t\"math\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n)\n\n// ProviderTemplate 描述一个已知 provider 的模板化添加配置。\n//\n// 设计目标:用户只需选择 provider + 输入 API Key,系统按 key 前缀选候选 baseURL,\n// 探测验证可用性后自动创建渠道,无需手填 baseURL / 选协议 / 配兼容开关。\n//\n// baseURL 按上游原生协议区分端点:Claude 请求优先走原生 Claude 入口;\n// Chat / Codex Responses 请求走 OpenAI Chat 兼容入口,由后端做协议转换。\n// ProviderTemplate 只描述来源和端点,不再承载 channel-presets 兼容开关;\n// autoManaged 渠道的模型与能力差异由后端智能调度/ModelResolver 处理。\ntype ProviderTemplate struct {\n\tProviderID string `json:\"providerId\"` // \"mimo\" / \"deepseek\" / ...\n\tAliases []string `json:\"aliases,omitempty\"`\n\tDisplayName string `json:\"displayName\"` // \"小米 MiMo\"\n\tDescription string `json:\"description\"` // key 前缀说明等\n\tChannelKind string `json:\"channelKind\"` // 默认 route 的渠道类型,兼容旧前端\n\tServiceType string `json:\"serviceType\"` // 默认 route 的服务类型,兼容旧前端\n\tOriginType string `json:\"originType\"` // official_api / official_token_plan / relay\n\tOriginTier string `json:\"originTier\"` // first / second\n\tKeyPrefixRules []KeyPrefixRule `json:\"keyPrefixRules\"` // key 前缀 → plan 判别\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 默认 route 的候选 baseURL\n\tRoutes []ProviderRoute `json:\"routes,omitempty\"`\n\t// ModelCostMultipliers 描述同一 provider 套餐内每次模型调用的相对消耗。\n\t// key 支持模型精确 ID 或通配符;值越低越省,只在质量与实测表现相同后参与选优。\n\tModelCostMultipliers map[string]float64 `json:\"modelCostMultipliers,omitempty\"`\n\t// ModelQualityPriorities 描述 provider 已确认的同质量档模型能力顺序,值越高越强。\n\t// 该映射允许不完整;解析器仅在同档候选全部命中时比较,避免未知模型被无依据降权。\n\tModelQualityPriorities map[string]int `json:\"modelQualityPriorities,omitempty\"`\n}\n\n// ProviderRoute 描述同一 provider 在某个 CCX 渠道协议下使用的原生上游入口。\ntype ProviderRoute struct {\n\tChannelKind string `json:\"channelKind\"` // \"messages\" / \"chat\" / \"responses\"\n\tServiceType string `json:\"serviceType\"` // \"claude\" / \"openai\" / \"responses\"\n\tDescription string `json:\"description,omitempty\"` // route 说明,仅展示/诊断\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 该 route 的候选 baseURL\n\t// SupportedModels 限定该 route 端点实际接受的模型,语义与 UpstreamConfig.SupportedModels 一致\n\t// (支持精确匹配、prefix* / *suffix / *contains*,以及 ! 前缀排除)。\n\t//\n\t// 仅在上游同一 baseURL 下不同协议端点的模型支持范围不一致时才需要填写:\n\t// 例如 DeepSeek 的 /v1/responses 只接受 deepseek-v4-flash,而 /v1/chat/completions\n\t// 同时接受 flash 与 pro。留空表示该 route 不额外限制模型。\n\tSupportedModels []string `json:\"supportedModels,omitempty\"`\n}\n\n// KeyPrefixRule 按 API Key 前缀判别 plan 类型。\ntype KeyPrefixRule struct {\n\tPrefix string `json:\"prefix\"` // \"sk-\" / \"tp-\"\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\"\n}\n\n// ProviderCandidate 一个候选 baseURL 及其归属信息。\ntype ProviderCandidate struct {\n\tBaseURL string `json:\"baseUrl\"` // 完整 baseURL,如 https://api.xiaomimimo.com/anthropic\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\" / \"\"\n\tRegion string `json:\"region\"` // \"cn\" / \"sgp\" / \"ams\" / \"global\" / \"\"\n\tPriority int `json:\"priority\"` // 同 plan 内探测优先级(数字越小越先探测)\n}\n\n// builtinProviderTemplates 编译期内置的已知 provider 模板。\n//\n// URL 来源(2026-07 联网核实官方文档):\n// - MiMo: https://mimo.mi.com/docs(按量 sk- / Token Plan tp-,TP 分 cn/sgp/ams 三区域集群)\n// - DeepSeek: https://api-docs.deepseek.com(Anthropic 兼容 /anthropic)\n// - Kimi: https://api.moonshot.ai/anthropic(全球)/ https://api.moonshot.cn/anthropic(中国)\n// - GLM: https://open.bigmodel.cn/api/anthropic(Claude)与 /api/paas/v4(OpenAI)\n// - 火山方舟: https://ark.cn-beijing.volces.com/api/plan(Agent Plan)与 /api/coding(Coding Plan)\n//\n// Claude route 的 baseURL 使用 Anthropic 兼容入口且不带 /v1(claude provider 会自动补 /v1/messages)。\n// Chat/Responses route 使用 OpenAI Chat 兼容入口,由 provider 自动补协议端点。\nvar builtinProviderTemplates = []ProviderTemplate{\n\t{\n\t\tProviderID: \"mimo\",\n\t\tDisplayName: \"小米 MiMo\",\n\t\tDescription: \"sk- 按量付费 / tp- Token Plan 订阅(Claude 走 /anthropic,Chat/Codex/Gemini 走 /v1)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"sk-\", PlanTag: \"payg\"},\n\t\t\t{Prefix: \"tp-\", PlanTag: \"token_plan\"},\n\t\t},\n\t\tCandidates: mimoClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages 原生 Anthropic 兼容入口\",\n\t\t\t\tCandidates: mimoClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"deepseek\",\n\t\tDisplayName: \"DeepSeek\",\n\t\tDescription: \"DeepSeek 官方 API(Claude Messages、OpenAI Chat 与 Responses 三协议原生兼容;Responses 仅支持 deepseek-v4-flash)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: deepseekClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages Anthropic 兼容入口\",\n\t\t\t\tCandidates: deepseekClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: deepseekOpenAICandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"responses\",\n\t\t\t\tDescription: \"OpenAI Responses 原生入口(仅 deepseek-v4-flash;v4-pro 预计 2026-08 初支持)\",\n\t\t\t\tCandidates: deepseekResponsesCandidates(),\n\t\t\t\t// 上游对不支持的模型返回 400 而非忽略,因此在渠道层就限定为正向白名单:\n\t\t\t\t// 未知新模型不会被误放进该端点,v4-pro 上线后在此追加即可。\n\t\t\t\tSupportedModels: []string{\"deepseek-v4-flash\"},\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"volcengine\",\n\t\tAliases: []string{\"volc-ark\"},\n\t\tDisplayName: \"火山方舟 Agent/Coding Plan\",\n\t\tDescription: \"ark- 套餐推理 Key。Agent Plan 支持 AFP 感知路由(详见 volcengine_afp_pricing.go);Coding Plan 计费规则未核验,仅使用通用定价。模型发现需为每个 Key 绑定火山云 Access Key。\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"ark-\", PlanTag: \"personal_plan\"},\n\t\t},\n\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Agent/Coding Plan Claude Messages 入口\",\n\t\t\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Agent/Coding Plan OpenAI Chat Completions 入口\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"kimi\",\n\t\tAliases: []string{\"kimi-code\"},\n\t\tDisplayName: \"Kimi (Moonshot)\",\n\t\tDescription: \"Moonshot Kimi 官方 API(按量与 Coding Plan,自动判别可用入口)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: kimiClaudeCandidates(),\n\t\tRoutes: standardProviderRoutes(\"claude\", kimiClaudeCandidates(), kimiOpenAICandidates()),\n\t},\n\t{\n\t\tProviderID: \"glm\",\n\t\tDisplayName: \"智谱 GLM\",\n\t\tDescription: \"智谱 GLM 官方 API(自动识别 id.secret Key;按量与 Coding Plan)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: glmClaudeCandidates(),\n\t\tRoutes: standardProviderRoutes(\"claude\", glmClaudeCandidates(), glmOpenAICandidates()),\n\t},\n\tcompshareProviderTemplate(),\n\tnewProviderTemplate(\n\t\t\"sensenova\",\n\t\t\"SenseNova\",\n\t\t\"商汤日日新官方 API\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://token.sensenova.cn\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://token.sensenova.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"minimax\",\n\t\t\"MiniMax\",\n\t\t\"MiniMax 官方 API\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.minimaxi.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.minimax.chat/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"dashscope\",\n\t\t\"阿里云 DashScope\",\n\t\t\"阿里云百炼按量、Coding Plan 与 Token Plan\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", dashScopeClaudeCandidates(), dashScopeOpenAICandidates()),\n\t),\n\tnewProviderTemplateWithAliases(\n\t\t\"opencode-zen\",\n\t\t[]string{\"opencode-go\"},\n\t\t\"OpenCode Zen / Go\",\n\t\t\"OpenCode 官方 Zen 按量与 Go 订阅模型网关\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"openai\", openCodeCandidates(), openCodeCandidates()),\n\t),\n\tnewProviderTemplate(\n\t\t\"tencent-lkeap\",\n\t\t\"腾讯云 TokenHub\",\n\t\t\"腾讯云大模型 Token Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/v3\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"qianfan\",\n\t\t\"百度千帆 Coding Plan\",\n\t\t\"百度智能云千帆 Coding Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://qianfan.baidubce.com/anthropic/coding\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://qianfan.baidubce.com/v2/coding#\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"xfyun\",\n\t\t\"讯飞星辰\",\n\t\t\"科大讯飞星辰 MaaS Coding Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\txfYunRoutes(),\n\t),\n\tnewProviderTemplate(\n\t\t\"openrouter\",\n\t\t\"OpenRouter\",\n\t\t\"OpenRouter 多模型聚合平台\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://openrouter.ai/api\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://openrouter.ai/api/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"modelscope\",\n\t\t\"ModelScope 魔搭\",\n\t\t\"阿里 ModelScope 官方推理服务\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api-inference.modelscope.cn\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api-inference.modelscope.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"originrouter\",\n\t\t\"极易云 OriginRouter\",\n\t\t\"极易云多模型 API 转发平台\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.easytransnote.com/coding\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.easytransnote.com/coding/v1\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n}\n\nvar zhipuAPIKeyPattern = regexp.MustCompile(`^[A-Za-z0-9]{20,}\\.[A-Za-z0-9]{10,}$`)\n\nfunc standardProviderRoutes(messagesServiceType string, messagesCandidates, openAICandidates []ProviderCandidate) []ProviderRoute {\n\tmessagesDescription := \"Claude Messages 原生 Anthropic 兼容入口\"\n\tif messagesServiceType == \"openai\" {\n\t\tmessagesDescription = \"Claude Messages 请求转换到 OpenAI Chat Completions\"\n\t}\n\treturn []ProviderRoute{\n\t\t{\n\t\t\tChannelKind: \"messages\",\n\t\t\tServiceType: messagesServiceType,\n\t\t\tDescription: messagesDescription,\n\t\t\tCandidates: messagesCandidates,\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"chat\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\tCandidates: openAICandidates,\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"responses\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\tCandidates: openAICandidates,\n\t\t},\n\t}\n}\n\nfunc newProviderTemplate(providerID, displayName, description, originType, originTier string, routes []ProviderRoute) ProviderTemplate {\n\treturn newProviderTemplateWithAliases(providerID, nil, displayName, description, originType, originTier, routes)\n}\n\nfunc newProviderTemplateWithAliases(providerID string, aliases []string, displayName, description, originType, originTier string, routes []ProviderRoute) ProviderTemplate {\n\ttmpl := ProviderTemplate{\n\t\tProviderID: providerID,\n\t\tAliases: aliases,\n\t\tDisplayName: displayName,\n\t\tDescription: description,\n\t\tOriginType: originType,\n\t\tOriginTier: originTier,\n\t\tRoutes: routes,\n\t}\n\tif len(routes) > 0 {\n\t\ttmpl.ChannelKind = routes[0].ChannelKind\n\t\ttmpl.ServiceType = routes[0].ServiceType\n\t\ttmpl.Candidates = routes[0].Candidates\n\t}\n\treturn tmpl\n}\n\nfunc compshareProviderTemplate() ProviderTemplate {\n\ttmpl := newProviderTemplate(\n\t\t\"compshare\",\n\t\t\"优云智算套餐\",\n\t\t\"UCloud 优云智算模型套餐与聚合服务\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://cp.compshare.cn\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://cp.compshare.cn/v1\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t)\n\t// 优云智算控制台的套餐消耗次数;glm-5.2 当前为限时倍率。\n\ttmpl.ModelCostMultipliers = map[string]float64{\n\t\t\"glm-5.2\": 2,\n\t\t\"glm-5.1\": 6,\n\t\t\"MiniMax-M2.7\": 2,\n\t\t\"kimi-k2.6\": 5,\n\t\t\"deepseek-ai/DeepSeek-V3.2\": 1,\n\t\t\"deepseek-v4-flash\": 1,\n\t}\n\ttmpl.ModelQualityPriorities = map[string]int{\n\t\t\"glm-5.1\": 2,\n\t\t\"kimi-k2.6\": 1,\n\t}\n\treturn tmpl\n}\n\nfunc deepseekClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com/anthropic\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\nfunc deepseekOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\n// deepseekResponsesCandidates DeepSeek 原生 Responses 入口。\n// 官方 base_url 为 https://api.deepseek.com,端点为标准 /v1/responses。\nfunc deepseekResponsesCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com/v1/responses\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\nfunc glmClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t}\n}\n\nfunc glmOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t// # 表示该路径已经是版本根,调用方不得再自动补 /v1。\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/paas/v4#\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/coding/paas/v4#\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n\nfunc kimiClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.moonshot.cn/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://api.moonshot.ai/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t\t{BaseURL: \"https://api.kimi.com/coding\", PlanTag: \"coding_plan\", Region: \"global\", Priority: 2},\n\t}\n}\n\nfunc kimiOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.moonshot.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://api.moonshot.ai/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t\t{BaseURL: \"https://api.kimi.com/coding/v1\", PlanTag: \"coding_plan\", Region: \"global\", Priority: 2},\n\t}\n}\n\nfunc dashScopeClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://dashscope.aliyuncs.com/apps/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://coding.dashscope.aliyuncs.com/apps/anthropic\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 2},\n\t}\n}\n\nfunc dashScopeOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://dashscope.aliyuncs.com/compatible-mode/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://coding.dashscope.aliyuncs.com/v1\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 2},\n\t}\n}\n\nfunc openCodeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://opencode.ai/zen/go/v1\", PlanTag: \"subscription\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://opencode.ai/zen/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t}\n}\n\nfunc xfYunRoutes() []ProviderRoute {\n\treturn []ProviderRoute{\n\t\t{\n\t\t\tChannelKind: \"messages\",\n\t\t\tServiceType: \"claude\",\n\t\t\tDescription: \"Claude Messages Coding Plan 入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/anthropic\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"chat\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"OpenAI Chat Completions Coding Plan 入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v2\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"responses\",\n\t\t\tServiceType: \"responses\",\n\t\t\tDescription: \"OpenAI Responses Coding Plan 原生入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v1/responses\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t}\n}\n\n// ListProviderTemplates 返回所有内置 provider 模板。\nfunc ListProviderTemplates() []ProviderTemplate {\n\tout := make([]ProviderTemplate, len(builtinProviderTemplates))\n\tcopy(out, builtinProviderTemplates)\n\treturn out\n}\n\n// GetProviderTemplate 按 providerId 查找模板,未找到返回 (nil, false)。\nfunc GetProviderTemplate(providerID string) (*ProviderTemplate, bool) {\n\tproviderID = strings.ToLower(strings.TrimSpace(providerID))\n\tfor i := range builtinProviderTemplates {\n\t\ttmpl := &builtinProviderTemplates[i]\n\t\tif strings.EqualFold(tmpl.ProviderID, providerID) {\n\t\t\treturn tmpl, true\n\t\t}\n\t\tfor _, alias := range tmpl.Aliases {\n\t\t\tif strings.EqualFold(alias, providerID) {\n\t\t\t\treturn tmpl, true\n\t\t\t}\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ModelCostMultiplierForModel 返回 provider 套餐内指定模型的相对消耗倍率。\n// 精确 ID 优先,随后按最长通配符匹配;非法、负数或非有限值视为未知。\nfunc (t *ProviderTemplate) ModelCostMultiplierForModel(modelID string) (float64, bool) {\n\tif t == nil {\n\t\treturn 0, false\n\t}\n\tmultiplier, _, ok := resolvePatternValueFold(modelID, t.ModelCostMultipliers)\n\tif !ok || multiplier < 0 || math.IsNaN(multiplier) || math.IsInf(multiplier, 0) {\n\t\treturn 0, false\n\t}\n\treturn multiplier, true\n}\n\n// ModelQualityPriorityForModel 返回 provider 已确认的同档模型能力优先级。\nfunc (t *ProviderTemplate) ModelQualityPriorityForModel(modelID string) (int, bool) {\n\tif t == nil {\n\t\treturn 0, false\n\t}\n\tpriority, _, ok := resolvePatternValueFold(modelID, t.ModelQualityPriorities)\n\tif !ok || priority <= 0 {\n\t\treturn 0, false\n\t}\n\treturn priority, true\n}\n\n// InferProviderIDFromBaseURL 仅按已知模板候选端点识别 provider。\n// 使用最长路径匹配,避免把仅承载同名模型的其他中转站误判为该渠道。\nfunc InferProviderIDFromBaseURL(baseURL string) (string, bool) {\n\ttarget, err := url.Parse(strings.TrimSuffix(strings.TrimSpace(baseURL), \"#\"))\n\tif err != nil || target.Hostname() == \"\" {\n\t\treturn \"\", false\n\t}\n\ttargetPath := strings.TrimRight(target.EscapedPath(), \"/\")\n\tbestProvider := \"\"\n\tbestPathLen := -1\n\tfor _, tmpl := range builtinProviderTemplates {\n\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\tfor _, candidate := range route.Candidates {\n\t\t\t\tcandidateURL, parseErr := url.Parse(strings.TrimSpace(candidate.BaseURL))\n\t\t\t\tif parseErr != nil || !sameURLServer(target, candidateURL) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tcandidatePath := strings.TrimRight(candidateURL.EscapedPath(), \"/\")\n\t\t\t\tif candidatePath != \"\" && targetPath != candidatePath && !strings.HasPrefix(targetPath, candidatePath+\"/\") {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif len(candidatePath) > bestPathLen {\n\t\t\t\t\tbestProvider = tmpl.ProviderID\n\t\t\t\t\tbestPathLen = len(candidatePath)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn bestProvider, bestProvider != \"\"\n}\n\n// InferProviderIDFromAPIKey 仅识别具有官方唯一格式的 API Key。\n// sk- 等共享格式无法可靠区分 provider,因此不会参与推断。\nfunc InferProviderIDFromAPIKey(apiKey string) (string, bool) {\n\tif zhipuAPIKeyPattern.MatchString(strings.TrimSpace(apiKey)) {\n\t\treturn \"glm\", true\n\t}\n\treturn \"\", false\n}\n\nfunc sameURLServer(left, right *url.URL) bool {\n\tif left == nil || right == nil || !strings.EqualFold(left.Hostname(), right.Hostname()) {\n\t\treturn false\n\t}\n\treturn effectiveURLPort(left) == effectiveURLPort(right)\n}\n\nfunc effectiveURLPort(value *url.URL) string {\n\tif value == nil {\n\t\treturn \"\"\n\t}\n\tif port := value.Port(); port != \"\" {\n\t\treturn port\n\t}\n\tswitch strings.ToLower(value.Scheme) {\n\tcase \"https\":\n\t\treturn \"443\"\n\tcase \"http\":\n\t\treturn \"80\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// AutoAddRoutes 返回 provider 快速添加时需要创建的渠道 route。\nfunc (t *ProviderTemplate) AutoAddRoutes() []ProviderRoute {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tif len(t.Routes) > 0 {\n\t\tout := make([]ProviderRoute, len(t.Routes))\n\t\tcopy(out, t.Routes)\n\t\treturn out\n\t}\n\tif t.ChannelKind == \"\" || t.ServiceType == \"\" {\n\t\treturn nil\n\t}\n\treturn []ProviderRoute{{\n\t\tChannelKind: t.ChannelKind,\n\t\tServiceType: t.ServiceType,\n\t\tCandidates: append([]ProviderCandidate(nil), t.Candidates...),\n\t}}\n}\n\n// SupportsChannelKind 判断 provider 是否可从指定渠道页发起快速添加。\nfunc (t *ProviderTemplate) SupportsChannelKind(kind string) bool {\n\tfor _, route := range t.AutoAddRoutes() {\n\t\tif route.ChannelKind == kind {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// CandidatesForKey 按 API Key 前缀返回默认 route 的候选 baseURL 顺序。\n//\n// 规则(对应用户决策):\n// 1. key 前缀命中某 PlanTag → 该 plan 的候选优先(按 Priority 升序),其余候选追加在后作为回退\n// 2. 前缀不匹配任何规则(或模板无前缀规则)→ 返回全部候选(按 Priority 升序)\n//\n// 返回的候选已按探测顺序排列,调用方依次探测,命中即用。\nfunc (t *ProviderTemplate) CandidatesForKey(apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\treturn t.CandidatesForRouteKey(ProviderRoute{Candidates: t.Candidates}, apiKey)\n}\n\n// CandidatesForRouteKey 按 API Key 前缀返回指定 route 的候选 baseURL 顺序。\nfunc (t *ProviderTemplate) CandidatesForRouteKey(route ProviderRoute, apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tmatchedPlan := \"\"\n\tfor _, rule := range t.KeyPrefixRules {\n\t\tif rule.Prefix != \"\" && strings.HasPrefix(apiKey, rule.Prefix) {\n\t\t\tmatchedPlan = rule.PlanTag\n\t\t\tbreak\n\t\t}\n\t}\n\n\tsortByPriority := func(list []ProviderCandidate) []ProviderCandidate {\n\t\tsorted := make([]ProviderCandidate, len(list))\n\t\tcopy(sorted, list)\n\t\t// 稳定插入排序(候选数量极少,无需 sort 包)\n\t\tfor i := 1; i < len(sorted); i++ {\n\t\t\tfor j := i; j > 0 && sorted[j].Priority < sorted[j-1].Priority; j-- {\n\t\t\t\tsorted[j], sorted[j-1] = sorted[j-1], sorted[j]\n\t\t\t}\n\t\t}\n\t\treturn sorted\n\t}\n\n\tif matchedPlan == \"\" {\n\t\treturn sortByPriority(route.Candidates)\n\t}\n\n\tpreferred := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfallback := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfor _, cand := range route.Candidates {\n\t\tif cand.PlanTag == matchedPlan {\n\t\t\tpreferred = append(preferred, cand)\n\t\t} else {\n\t\t\tfallback = append(fallback, cand)\n\t\t}\n\t}\n\treturn append(sortByPriority(preferred), sortByPriority(fallback)...)\n}\n\nfunc mimoClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc mimoChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc volcenginePlanClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n\nfunc volcenginePlanChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n"}, "45815d680b1a4b6df308dbd2b2da93761d870297": {"t": 1785759313.8572848, "n": 24274, "h": "9c45ff4f2e00d972", "c": "package config\n\nimport (\n\t\"math\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n)\n\n// ProviderTemplate 描述一个已知 provider 的模板化添加配置。\n//\n// 设计目标:用户只需选择 provider + 输入 API Key,系统按 key 前缀选候选 baseURL,\n// 探测验证可用性后自动创建渠道,无需手填 baseURL / 选协议 / 配兼容开关。\n//\n// baseURL 按上游原生协议区分端点:Claude 请求优先走原生 Claude 入口;\n// Chat / Codex Responses 请求走 OpenAI Chat 兼容入口,由后端做协议转换。\n// ProviderTemplate 只描述来源和端点,不再承载 channel-presets 兼容开关;\n// autoManaged 渠道的模型与能力差异由后端智能调度/ModelResolver 处理。\ntype ProviderTemplate struct {\n\tProviderID string `json:\"providerId\"` // \"mimo\" / \"deepseek\" / ...\n\tAliases []string `json:\"aliases,omitempty\"`\n\tDisplayName string `json:\"displayName\"` // \"小米 MiMo\"\n\tDescription string `json:\"description\"` // key 前缀说明等\n\tChannelKind string `json:\"channelKind\"` // 默认 route 的渠道类型,兼容旧前端\n\tServiceType string `json:\"serviceType\"` // 默认 route 的服务类型,兼容旧前端\n\tOriginType string `json:\"originType\"` // official_api / official_token_plan / relay\n\tOriginTier string `json:\"originTier\"` // first / second\n\tKeyPrefixRules []KeyPrefixRule `json:\"keyPrefixRules\"` // key 前缀 → plan 判别\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 默认 route 的候选 baseURL\n\tRoutes []ProviderRoute `json:\"routes,omitempty\"`\n\t// ModelCostMultipliers 描述同一 provider 套餐内每次模型调用的相对消耗。\n\t// key 支持模型精确 ID 或通配符;值越低越省,只在质量与实测表现相同后参与选优。\n\tModelCostMultipliers map[string]float64 `json:\"modelCostMultipliers,omitempty\"`\n\t// ModelQualityPriorities 描述 provider 已确认的同质量档模型能力顺序,值越高越强。\n\t// 该映射允许不完整;解析器仅在同档候选全部命中时比较,避免未知模型被无依据降权。\n\tModelQualityPriorities map[string]int `json:\"modelQualityPriorities,omitempty\"`\n}\n\n// ProviderRoute 描述同一 provider 在某个 CCX 渠道协议下使用的原生上游入口。\ntype ProviderRoute struct {\n\tChannelKind string `json:\"channelKind\"` // \"messages\" / \"chat\" / \"responses\"\n\tServiceType string `json:\"serviceType\"` // \"claude\" / \"openai\" / \"responses\"\n\tDescription string `json:\"description,omitempty\"` // route 说明,仅展示/诊断\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 该 route 的候选 baseURL\n}\n\n// KeyPrefixRule 按 API Key 前缀判别 plan 类型。\ntype KeyPrefixRule struct {\n\tPrefix string `json:\"prefix\"` // \"sk-\" / \"tp-\"\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\"\n}\n\n// ProviderCandidate 一个候选 baseURL 及其归属信息。\ntype ProviderCandidate struct {\n\tBaseURL string `json:\"baseUrl\"` // 完整 baseURL,如 https://api.xiaomimimo.com/anthropic\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\" / \"\"\n\tRegion string `json:\"region\"` // \"cn\" / \"sgp\" / \"ams\" / \"global\" / \"\"\n\tPriority int `json:\"priority\"` // 同 plan 内探测优先级(数字越小越先探测)\n}\n\n// builtinProviderTemplates 编译期内置的已知 provider 模板。\n//\n// URL 来源(2026-07 联网核实官方文档):\n// - MiMo: https://mimo.mi.com/docs(按量 sk- / Token Plan tp-,TP 分 cn/sgp/ams 三区域集群)\n// - DeepSeek: https://api-docs.deepseek.com(Anthropic 兼容 /anthropic)\n// - Kimi: https://api.moonshot.ai/anthropic(全球)/ https://api.moonshot.cn/anthropic(中国)\n// - GLM: https://open.bigmodel.cn/api/anthropic(Claude)与 /api/paas/v4(OpenAI)\n// - 火山方舟: https://ark.cn-beijing.volces.com/api/plan(Agent Plan)与 /api/coding(Coding Plan)\n//\n// Claude route 的 baseURL 使用 Anthropic 兼容入口且不带 /v1(claude provider 会自动补 /v1/messages)。\n// Chat/Responses route 使用 OpenAI Chat 兼容入口,由 provider 自动补协议端点。\nvar builtinProviderTemplates = []ProviderTemplate{\n\t{\n\t\tProviderID: \"mimo\",\n\t\tDisplayName: \"小米 MiMo\",\n\t\tDescription: \"sk- 按量付费 / tp- Token Plan 订阅(Claude 走 /anthropic,Chat/Codex/Gemini 走 /v1)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"sk-\", PlanTag: \"payg\"},\n\t\t\t{Prefix: \"tp-\", PlanTag: \"token_plan\"},\n\t\t},\n\t\tCandidates: mimoClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages 原生 Anthropic 兼容入口\",\n\t\t\t\tCandidates: mimoClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"deepseek\",\n\t\tDisplayName: \"DeepSeek\",\n\t\tDescription: \"DeepSeek 官方 API(Claude Messages 与 OpenAI Chat 兼容;Responses 由 CCX 转换)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: deepseekClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages Anthropic 兼容入口\",\n\t\t\t\tCandidates: deepseekClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: deepseekOpenAICandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: deepseekOpenAICandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"volcengine\",\n\t\tAliases: []string{\"volc-ark\"},\n\t\tDisplayName: \"火山方舟 Agent/Coding Plan\",\n\t\tDescription: \"ark- 套餐推理 Key。Agent Plan 支持 AFP 感知路由(详见 volcengine_afp_pricing.go);Coding Plan 计费规则未核验,仅使用通用定价。模型发现需为每个 Key 绑定火山云 Access Key。\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"ark-\", PlanTag: \"personal_plan\"},\n\t\t},\n\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Agent/Coding Plan Claude Messages 入口\",\n\t\t\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Agent/Coding Plan OpenAI Chat Completions 入口\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"kimi\",\n\t\tAliases: []string{\"kimi-code\"},\n\t\tDisplayName: \"Kimi (Moonshot)\",\n\t\tDescription: \"Moonshot Kimi 官方 API(按量与 Coding Plan,自动判别可用入口)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: kimiClaudeCandidates(),\n\t\tRoutes: standardProviderRoutes(\"claude\", kimiClaudeCandidates(), kimiOpenAICandidates()),\n\t},\n\t{\n\t\tProviderID: \"glm\",\n\t\tDisplayName: \"智谱 GLM\",\n\t\tDescription: \"智谱 GLM 官方 API(自动识别 id.secret Key;按量与 Coding Plan)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: glmClaudeCandidates(),\n\t\tRoutes: standardProviderRoutes(\"claude\", glmClaudeCandidates(), glmOpenAICandidates()),\n\t},\n\tcompshareProviderTemplate(),\n\tnewProviderTemplate(\n\t\t\"sensenova\",\n\t\t\"SenseNova\",\n\t\t\"商汤日日新官方 API\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://token.sensenova.cn\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://token.sensenova.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"minimax\",\n\t\t\"MiniMax\",\n\t\t\"MiniMax 官方 API\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.minimaxi.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.minimax.chat/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"dashscope\",\n\t\t\"阿里云 DashScope\",\n\t\t\"阿里云百炼按量、Coding Plan 与 Token Plan\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", dashScopeClaudeCandidates(), dashScopeOpenAICandidates()),\n\t),\n\tnewProviderTemplateWithAliases(\n\t\t\"opencode-zen\",\n\t\t[]string{\"opencode-go\"},\n\t\t\"OpenCode Zen / Go\",\n\t\t\"OpenCode 官方 Zen 按量与 Go 订阅模型网关\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"openai\", openCodeCandidates(), openCodeCandidates()),\n\t),\n\tnewProviderTemplate(\n\t\t\"tencent-lkeap\",\n\t\t\"腾讯云 TokenHub\",\n\t\t\"腾讯云大模型 Token Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/v3\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"qianfan\",\n\t\t\"百度千帆 Coding Plan\",\n\t\t\"百度智能云千帆 Coding Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://qianfan.baidubce.com/anthropic/coding\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://qianfan.baidubce.com/v2/coding#\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"xfyun\",\n\t\t\"讯飞星辰\",\n\t\t\"科大讯飞星辰 MaaS Coding Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\txfYunRoutes(),\n\t),\n\tnewProviderTemplate(\n\t\t\"openrouter\",\n\t\t\"OpenRouter\",\n\t\t\"OpenRouter 多模型聚合平台\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://openrouter.ai/api\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://openrouter.ai/api/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"modelscope\",\n\t\t\"ModelScope 魔搭\",\n\t\t\"阿里 ModelScope 官方推理服务\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api-inference.modelscope.cn\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api-inference.modelscope.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"originrouter\",\n\t\t\"极易云 OriginRouter\",\n\t\t\"极易云多模型 API 转发平台\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.easytransnote.com/coding\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.easytransnote.com/coding/v1\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n}\n\nvar zhipuAPIKeyPattern = regexp.MustCompile(`^[A-Za-z0-9]{20,}\\.[A-Za-z0-9]{10,}$`)\n\nfunc standardProviderRoutes(messagesServiceType string, messagesCandidates, openAICandidates []ProviderCandidate) []ProviderRoute {\n\tmessagesDescription := \"Claude Messages 原生 Anthropic 兼容入口\"\n\tif messagesServiceType == \"openai\" {\n\t\tmessagesDescription = \"Claude Messages 请求转换到 OpenAI Chat Completions\"\n\t}\n\treturn []ProviderRoute{\n\t\t{\n\t\t\tChannelKind: \"messages\",\n\t\t\tServiceType: messagesServiceType,\n\t\t\tDescription: messagesDescription,\n\t\t\tCandidates: messagesCandidates,\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"chat\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\tCandidates: openAICandidates,\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"responses\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\tCandidates: openAICandidates,\n\t\t},\n\t}\n}\n\nfunc newProviderTemplate(providerID, displayName, description, originType, originTier string, routes []ProviderRoute) ProviderTemplate {\n\treturn newProviderTemplateWithAliases(providerID, nil, displayName, description, originType, originTier, routes)\n}\n\nfunc newProviderTemplateWithAliases(providerID string, aliases []string, displayName, description, originType, originTier string, routes []ProviderRoute) ProviderTemplate {\n\ttmpl := ProviderTemplate{\n\t\tProviderID: providerID,\n\t\tAliases: aliases,\n\t\tDisplayName: displayName,\n\t\tDescription: description,\n\t\tOriginType: originType,\n\t\tOriginTier: originTier,\n\t\tRoutes: routes,\n\t}\n\tif len(routes) > 0 {\n\t\ttmpl.ChannelKind = routes[0].ChannelKind\n\t\ttmpl.ServiceType = routes[0].ServiceType\n\t\ttmpl.Candidates = routes[0].Candidates\n\t}\n\treturn tmpl\n}\n\nfunc compshareProviderTemplate() ProviderTemplate {\n\ttmpl := newProviderTemplate(\n\t\t\"compshare\",\n\t\t\"优云智算套餐\",\n\t\t\"UCloud 优云智算模型套餐与聚合服务\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://cp.compshare.cn\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://cp.compshare.cn/v1\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t)\n\t// 优云智算控制台的套餐消耗次数;glm-5.2 当前为限时倍率。\n\ttmpl.ModelCostMultipliers = map[string]float64{\n\t\t\"glm-5.2\": 2,\n\t\t\"glm-5.1\": 6,\n\t\t\"MiniMax-M2.7\": 2,\n\t\t\"kimi-k2.6\": 5,\n\t\t\"deepseek-ai/DeepSeek-V3.2\": 1,\n\t\t\"deepseek-v4-flash\": 1,\n\t}\n\ttmpl.ModelQualityPriorities = map[string]int{\n\t\t\"glm-5.1\": 2,\n\t\t\"kimi-k2.6\": 1,\n\t}\n\treturn tmpl\n}\n\nfunc deepseekClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com/anthropic\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\nfunc deepseekOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\nfunc glmClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t}\n}\n\nfunc glmOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t// # 表示该路径已经是版本根,调用方不得再自动补 /v1。\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/paas/v4#\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/coding/paas/v4#\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n\nfunc kimiClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.moonshot.cn/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://api.moonshot.ai/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t\t{BaseURL: \"https://api.kimi.com/coding\", PlanTag: \"coding_plan\", Region: \"global\", Priority: 2},\n\t}\n}\n\nfunc kimiOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.moonshot.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://api.moonshot.ai/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t\t{BaseURL: \"https://api.kimi.com/coding/v1\", PlanTag: \"coding_plan\", Region: \"global\", Priority: 2},\n\t}\n}\n\nfunc dashScopeClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://dashscope.aliyuncs.com/apps/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://coding.dashscope.aliyuncs.com/apps/anthropic\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 2},\n\t}\n}\n\nfunc dashScopeOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://dashscope.aliyuncs.com/compatible-mode/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://coding.dashscope.aliyuncs.com/v1\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 2},\n\t}\n}\n\nfunc openCodeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://opencode.ai/zen/go/v1\", PlanTag: \"subscription\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://opencode.ai/zen/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t}\n}\n\nfunc xfYunRoutes() []ProviderRoute {\n\treturn []ProviderRoute{\n\t\t{\n\t\t\tChannelKind: \"messages\",\n\t\t\tServiceType: \"claude\",\n\t\t\tDescription: \"Claude Messages Coding Plan 入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/anthropic\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"chat\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"OpenAI Chat Completions Coding Plan 入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v2\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"responses\",\n\t\t\tServiceType: \"responses\",\n\t\t\tDescription: \"OpenAI Responses Coding Plan 原生入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v1/responses\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t}\n}\n\n// ListProviderTemplates 返回所有内置 provider 模板。\nfunc ListProviderTemplates() []ProviderTemplate {\n\tout := make([]ProviderTemplate, len(builtinProviderTemplates))\n\tcopy(out, builtinProviderTemplates)\n\treturn out\n}\n\n// GetProviderTemplate 按 providerId 查找模板,未找到返回 (nil, false)。\nfunc GetProviderTemplate(providerID string) (*ProviderTemplate, bool) {\n\tproviderID = strings.ToLower(strings.TrimSpace(providerID))\n\tfor i := range builtinProviderTemplates {\n\t\ttmpl := &builtinProviderTemplates[i]\n\t\tif strings.EqualFold(tmpl.ProviderID, providerID) {\n\t\t\treturn tmpl, true\n\t\t}\n\t\tfor _, alias := range tmpl.Aliases {\n\t\t\tif strings.EqualFold(alias, providerID) {\n\t\t\t\treturn tmpl, true\n\t\t\t}\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ModelCostMultiplierForModel 返回 provider 套餐内指定模型的相对消耗倍率。\n// 精确 ID 优先,随后按最长通配符匹配;非法、负数或非有限值视为未知。\nfunc (t *ProviderTemplate) ModelCostMultiplierForModel(modelID string) (float64, bool) {\n\tif t == nil {\n\t\treturn 0, false\n\t}\n\tmultiplier, _, ok := resolvePatternValueFold(modelID, t.ModelCostMultipliers)\n\tif !ok || multiplier < 0 || math.IsNaN(multiplier) || math.IsInf(multiplier, 0) {\n\t\treturn 0, false\n\t}\n\treturn multiplier, true\n}\n\n// ModelQualityPriorityForModel 返回 provider 已确认的同档模型能力优先级。\nfunc (t *ProviderTemplate) ModelQualityPriorityForModel(modelID string) (int, bool) {\n\tif t == nil {\n\t\treturn 0, false\n\t}\n\tpriority, _, ok := resolvePatternValueFold(modelID, t.ModelQualityPriorities)\n\tif !ok || priority <= 0 {\n\t\treturn 0, false\n\t}\n\treturn priority, true\n}\n\n// InferProviderIDFromBaseURL 仅按已知模板候选端点识别 provider。\n// 使用最长路径匹配,避免把仅承载同名模型的其他中转站误判为该渠道。\nfunc InferProviderIDFromBaseURL(baseURL string) (string, bool) {\n\ttarget, err := url.Parse(strings.TrimSuffix(strings.TrimSpace(baseURL), \"#\"))\n\tif err != nil || target.Hostname() == \"\" {\n\t\treturn \"\", false\n\t}\n\ttargetPath := strings.TrimRight(target.EscapedPath(), \"/\")\n\tbestProvider := \"\"\n\tbestPathLen := -1\n\tfor _, tmpl := range builtinProviderTemplates {\n\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\tfor _, candidate := range route.Candidates {\n\t\t\t\tcandidateURL, parseErr := url.Parse(strings.TrimSpace(candidate.BaseURL))\n\t\t\t\tif parseErr != nil || !sameURLServer(target, candidateURL) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tcandidatePath := strings.TrimRight(candidateURL.EscapedPath(), \"/\")\n\t\t\t\tif candidatePath != \"\" && targetPath != candidatePath && !strings.HasPrefix(targetPath, candidatePath+\"/\") {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif len(candidatePath) > bestPathLen {\n\t\t\t\t\tbestProvider = tmpl.ProviderID\n\t\t\t\t\tbestPathLen = len(candidatePath)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn bestProvider, bestProvider != \"\"\n}\n\n// InferProviderIDFromAPIKey 仅识别具有官方唯一格式的 API Key。\n// sk- 等共享格式无法可靠区分 provider,因此不会参与推断。\nfunc InferProviderIDFromAPIKey(apiKey string) (string, bool) {\n\tif zhipuAPIKeyPattern.MatchString(strings.TrimSpace(apiKey)) {\n\t\treturn \"glm\", true\n\t}\n\treturn \"\", false\n}\n\nfunc sameURLServer(left, right *url.URL) bool {\n\tif left == nil || right == nil || !strings.EqualFold(left.Hostname(), right.Hostname()) {\n\t\treturn false\n\t}\n\treturn effectiveURLPort(left) == effectiveURLPort(right)\n}\n\nfunc effectiveURLPort(value *url.URL) string {\n\tif value == nil {\n\t\treturn \"\"\n\t}\n\tif port := value.Port(); port != \"\" {\n\t\treturn port\n\t}\n\tswitch strings.ToLower(value.Scheme) {\n\tcase \"https\":\n\t\treturn \"443\"\n\tcase \"http\":\n\t\treturn \"80\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// AutoAddRoutes 返回 provider 快速添加时需要创建的渠道 route。\nfunc (t *ProviderTemplate) AutoAddRoutes() []ProviderRoute {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tif len(t.Routes) > 0 {\n\t\tout := make([]ProviderRoute, len(t.Routes))\n\t\tcopy(out, t.Routes)\n\t\treturn out\n\t}\n\tif t.ChannelKind == \"\" || t.ServiceType == \"\" {\n\t\treturn nil\n\t}\n\treturn []ProviderRoute{{\n\t\tChannelKind: t.ChannelKind,\n\t\tServiceType: t.ServiceType,\n\t\tCandidates: append([]ProviderCandidate(nil), t.Candidates...),\n\t}}\n}\n\n// SupportsChannelKind 判断 provider 是否可从指定渠道页发起快速添加。\nfunc (t *ProviderTemplate) SupportsChannelKind(kind string) bool {\n\tfor _, route := range t.AutoAddRoutes() {\n\t\tif route.ChannelKind == kind {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// CandidatesForKey 按 API Key 前缀返回默认 route 的候选 baseURL 顺序。\n//\n// 规则(对应用户决策):\n// 1. key 前缀命中某 PlanTag → 该 plan 的候选优先(按 Priority 升序),其余候选追加在后作为回退\n// 2. 前缀不匹配任何规则(或模板无前缀规则)→ 返回全部候选(按 Priority 升序)\n//\n// 返回的候选已按探测顺序排列,调用方依次探测,命中即用。\nfunc (t *ProviderTemplate) CandidatesForKey(apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\treturn t.CandidatesForRouteKey(ProviderRoute{Candidates: t.Candidates}, apiKey)\n}\n\n// CandidatesForRouteKey 按 API Key 前缀返回指定 route 的候选 baseURL 顺序。\nfunc (t *ProviderTemplate) CandidatesForRouteKey(route ProviderRoute, apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tmatchedPlan := \"\"\n\tfor _, rule := range t.KeyPrefixRules {\n\t\tif rule.Prefix != \"\" && strings.HasPrefix(apiKey, rule.Prefix) {\n\t\t\tmatchedPlan = rule.PlanTag\n\t\t\tbreak\n\t\t}\n\t}\n\n\tsortByPriority := func(list []ProviderCandidate) []ProviderCandidate {\n\t\tsorted := make([]ProviderCandidate, len(list))\n\t\tcopy(sorted, list)\n\t\t// 稳定插入排序(候选数量极少,无需 sort 包)\n\t\tfor i := 1; i < len(sorted); i++ {\n\t\t\tfor j := i; j > 0 && sorted[j].Priority < sorted[j-1].Priority; j-- {\n\t\t\t\tsorted[j], sorted[j-1] = sorted[j-1], sorted[j]\n\t\t\t}\n\t\t}\n\t\treturn sorted\n\t}\n\n\tif matchedPlan == \"\" {\n\t\treturn sortByPriority(route.Candidates)\n\t}\n\n\tpreferred := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfallback := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfor _, cand := range route.Candidates {\n\t\tif cand.PlanTag == matchedPlan {\n\t\t\tpreferred = append(preferred, cand)\n\t\t} else {\n\t\t\tfallback = append(fallback, cand)\n\t\t}\n\t}\n\treturn append(sortByPriority(preferred), sortByPriority(fallback)...)\n}\n\nfunc mimoClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc mimoChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc volcenginePlanClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n\nfunc volcenginePlanChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n"}, "59c3927ffbcde49bc2f2a8495991028e87c7deeb": {"t": 1785759317.1834695, "n": 24202, "h": "4f2329c526841828", "c": "package config\n\nimport (\n\t\"math\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n)\n\n// ProviderTemplate 描述一个已知 provider 的模板化添加配置。\n//\n// 设计目标:用户只需选择 provider + 输入 API Key,系统按 key 前缀选候选 baseURL,\n// 探测验证可用性后自动创建渠道,无需手填 baseURL / 选协议 / 配兼容开关。\n//\n// baseURL 按上游原生协议区分端点:Claude 请求优先走原生 Claude 入口;\n// Chat / Codex Responses 请求走 OpenAI Chat 兼容入口,由后端做协议转换。\n// ProviderTemplate 只描述来源和端点,不再承载 channel-presets 兼容开关;\n// autoManaged 渠道的模型与能力差异由后端智能调度/ModelResolver 处理。\ntype ProviderTemplate struct {\n\tProviderID string `json:\"providerId\"` // \"mimo\" / \"deepseek\" / ...\n\tAliases []string `json:\"aliases,omitempty\"`\n\tDisplayName string `json:\"displayName\"` // \"小米 MiMo\"\n\tDescription string `json:\"description\"` // key 前缀说明等\n\tChannelKind string `json:\"channelKind\"` // 默认 route 的渠道类型,兼容旧前端\n\tServiceType string `json:\"serviceType\"` // 默认 route 的服务类型,兼容旧前端\n\tOriginType string `json:\"originType\"` // official_api / official_token_plan / relay\n\tOriginTier string `json:\"originTier\"` // first / second\n\tKeyPrefixRules []KeyPrefixRule `json:\"keyPrefixRules\"` // key 前缀 → plan 判别\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 默认 route 的候选 baseURL\n\tRoutes []ProviderRoute `json:\"routes,omitempty\"`\n\t// ModelCostMultipliers 描述同一 provider 套餐内每次模型调用的相对消耗。\n\t// key 支持模型精确 ID 或通配符;值越低越省,只在质量与实测表现相同后参与选优。\n\tModelCostMultipliers map[string]float64 `json:\"modelCostMultipliers,omitempty\"`\n\t// ModelQualityPriorities 描述 provider 已确认的同质量档模型能力顺序,值越高越强。\n\t// 该映射允许不完整;解析器仅在同档候选全部命中时比较,避免未知模型被无依据降权。\n\tModelQualityPriorities map[string]int `json:\"modelQualityPriorities,omitempty\"`\n}\n\n// ProviderRoute 描述同一 provider 在某个 CCX 渠道协议下使用的原生上游入口。\ntype ProviderRoute struct {\n\tChannelKind string `json:\"channelKind\"` // \"messages\" / \"chat\" / \"responses\"\n\tServiceType string `json:\"serviceType\"` // \"claude\" / \"openai\" / \"responses\"\n\tDescription string `json:\"description,omitempty\"` // route 说明,仅展示/诊断\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 该 route 的候选 baseURL\n}\n\n// KeyPrefixRule 按 API Key 前缀判别 plan 类型。\ntype KeyPrefixRule struct {\n\tPrefix string `json:\"prefix\"` // \"sk-\" / \"tp-\"\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\"\n}\n\n// ProviderCandidate 一个候选 baseURL 及其归属信息。\ntype ProviderCandidate struct {\n\tBaseURL string `json:\"baseUrl\"` // 完整 baseURL,如 https://api.xiaomimimo.com/anthropic\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\" / \"\"\n\tRegion string `json:\"region\"` // \"cn\" / \"sgp\" / \"ams\" / \"global\" / \"\"\n\tPriority int `json:\"priority\"` // 同 plan 内探测优先级(数字越小越先探测)\n}\n\n// builtinProviderTemplates 编译期内置的已知 provider 模板。\n//\n// URL 来源(2026-07 联网核实官方文档):\n// - MiMo: https://mimo.mi.com/docs(按量 sk- / Token Plan tp-,TP 分 cn/sgp/ams 三区域集群)\n// - DeepSeek: https://api-docs.deepseek.com(Anthropic 兼容 /anthropic)\n// - Kimi: https://api.moonshot.ai/anthropic(全球)/ https://api.moonshot.cn/anthropic(中国)\n// - GLM: https://open.bigmodel.cn/api/anthropic(Claude)与 /api/paas/v4(OpenAI)\n// - 火山方舟: https://ark.cn-beijing.volces.com/api/plan(Agent Plan)与 /api/coding(Coding Plan)\n//\n// Claude route 的 baseURL 使用 Anthropic 兼容入口且不带 /v1(claude provider 会自动补 /v1/messages)。\n// Chat/Responses route 使用 OpenAI Chat 兼容入口,由 provider 自动补协议端点。\nvar builtinProviderTemplates = []ProviderTemplate{\n\t{\n\t\tProviderID: \"mimo\",\n\t\tDisplayName: \"小米 MiMo\",\n\t\tDescription: \"sk- 按量付费 / tp- Token Plan 订阅(Claude 走 /anthropic,Chat/Codex/Gemini 走 /v1)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"sk-\", PlanTag: \"payg\"},\n\t\t\t{Prefix: \"tp-\", PlanTag: \"token_plan\"},\n\t\t},\n\t\tCandidates: mimoClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages 原生 Anthropic 兼容入口\",\n\t\t\t\tCandidates: mimoClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"deepseek\",\n\t\tDisplayName: \"DeepSeek\",\n\t\tDescription: \"DeepSeek 官方 API(Claude Messages 与 OpenAI Chat 兼容;Responses 由 CCX 转换)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: deepseekClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages Anthropic 兼容入口\",\n\t\t\t\tCandidates: deepseekClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: deepseekOpenAICandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: deepseekOpenAICandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"volcengine\",\n\t\tAliases: []string{\"volc-ark\"},\n\t\tDisplayName: \"火山方舟 Agent/Coding Plan\",\n\t\tDescription: \"ark- 套餐推理 Key(系统自动识别套餐;模型发现需为每个 Key 绑定火山云 Access Key)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"ark-\", PlanTag: \"personal_plan\"},\n\t\t},\n\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Agent/Coding Plan Claude Messages 入口\",\n\t\t\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Agent/Coding Plan OpenAI Chat Completions 入口\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"kimi\",\n\t\tAliases: []string{\"kimi-code\"},\n\t\tDisplayName: \"Kimi (Moonshot)\",\n\t\tDescription: \"Moonshot Kimi 官方 API(按量与 Coding Plan,自动判别可用入口)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: kimiClaudeCandidates(),\n\t\tRoutes: standardProviderRoutes(\"claude\", kimiClaudeCandidates(), kimiOpenAICandidates()),\n\t},\n\t{\n\t\tProviderID: \"glm\",\n\t\tDisplayName: \"智谱 GLM\",\n\t\tDescription: \"智谱 GLM 官方 API(自动识别 id.secret Key;按量与 Coding Plan)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: glmClaudeCandidates(),\n\t\tRoutes: standardProviderRoutes(\"claude\", glmClaudeCandidates(), glmOpenAICandidates()),\n\t},\n\tcompshareProviderTemplate(),\n\tnewProviderTemplate(\n\t\t\"sensenova\",\n\t\t\"SenseNova\",\n\t\t\"商汤日日新官方 API\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://token.sensenova.cn\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://token.sensenova.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"minimax\",\n\t\t\"MiniMax\",\n\t\t\"MiniMax 官方 API\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.minimaxi.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.minimax.chat/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"dashscope\",\n\t\t\"阿里云 DashScope\",\n\t\t\"阿里云百炼按量、Coding Plan 与 Token Plan\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", dashScopeClaudeCandidates(), dashScopeOpenAICandidates()),\n\t),\n\tnewProviderTemplateWithAliases(\n\t\t\"opencode-zen\",\n\t\t[]string{\"opencode-go\"},\n\t\t\"OpenCode Zen / Go\",\n\t\t\"OpenCode 官方 Zen 按量与 Go 订阅模型网关\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"openai\", openCodeCandidates(), openCodeCandidates()),\n\t),\n\tnewProviderTemplate(\n\t\t\"tencent-lkeap\",\n\t\t\"腾讯云 TokenHub\",\n\t\t\"腾讯云大模型 Token Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/v3\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"qianfan\",\n\t\t\"百度千帆 Coding Plan\",\n\t\t\"百度智能云千帆 Coding Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://qianfan.baidubce.com/anthropic/coding\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://qianfan.baidubce.com/v2/coding#\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"xfyun\",\n\t\t\"讯飞星辰\",\n\t\t\"科大讯飞星辰 MaaS Coding Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\txfYunRoutes(),\n\t),\n\tnewProviderTemplate(\n\t\t\"openrouter\",\n\t\t\"OpenRouter\",\n\t\t\"OpenRouter 多模型聚合平台\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://openrouter.ai/api\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://openrouter.ai/api/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"modelscope\",\n\t\t\"ModelScope 魔搭\",\n\t\t\"阿里 ModelScope 官方推理服务\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api-inference.modelscope.cn\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api-inference.modelscope.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"originrouter\",\n\t\t\"极易云 OriginRouter\",\n\t\t\"极易云多模型 API 转发平台\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.easytransnote.com/coding\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.easytransnote.com/coding/v1\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n}\n\nvar zhipuAPIKeyPattern = regexp.MustCompile(`^[A-Za-z0-9]{20,}\\.[A-Za-z0-9]{10,}$`)\n\nfunc standardProviderRoutes(messagesServiceType string, messagesCandidates, openAICandidates []ProviderCandidate) []ProviderRoute {\n\tmessagesDescription := \"Claude Messages 原生 Anthropic 兼容入口\"\n\tif messagesServiceType == \"openai\" {\n\t\tmessagesDescription = \"Claude Messages 请求转换到 OpenAI Chat Completions\"\n\t}\n\treturn []ProviderRoute{\n\t\t{\n\t\t\tChannelKind: \"messages\",\n\t\t\tServiceType: messagesServiceType,\n\t\t\tDescription: messagesDescription,\n\t\t\tCandidates: messagesCandidates,\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"chat\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\tCandidates: openAICandidates,\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"responses\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\tCandidates: openAICandidates,\n\t\t},\n\t}\n}\n\nfunc newProviderTemplate(providerID, displayName, description, originType, originTier string, routes []ProviderRoute) ProviderTemplate {\n\treturn newProviderTemplateWithAliases(providerID, nil, displayName, description, originType, originTier, routes)\n}\n\nfunc newProviderTemplateWithAliases(providerID string, aliases []string, displayName, description, originType, originTier string, routes []ProviderRoute) ProviderTemplate {\n\ttmpl := ProviderTemplate{\n\t\tProviderID: providerID,\n\t\tAliases: aliases,\n\t\tDisplayName: displayName,\n\t\tDescription: description,\n\t\tOriginType: originType,\n\t\tOriginTier: originTier,\n\t\tRoutes: routes,\n\t}\n\tif len(routes) > 0 {\n\t\ttmpl.ChannelKind = routes[0].ChannelKind\n\t\ttmpl.ServiceType = routes[0].ServiceType\n\t\ttmpl.Candidates = routes[0].Candidates\n\t}\n\treturn tmpl\n}\n\nfunc compshareProviderTemplate() ProviderTemplate {\n\ttmpl := newProviderTemplate(\n\t\t\"compshare\",\n\t\t\"优云智算套餐\",\n\t\t\"UCloud 优云智算模型套餐与聚合服务\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://cp.compshare.cn\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://cp.compshare.cn/v1\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t)\n\t// 优云智算控制台的套餐消耗次数;glm-5.2 当前为限时倍率。\n\ttmpl.ModelCostMultipliers = map[string]float64{\n\t\t\"glm-5.2\": 2,\n\t\t\"glm-5.1\": 6,\n\t\t\"MiniMax-M2.7\": 2,\n\t\t\"kimi-k2.6\": 5,\n\t\t\"deepseek-ai/DeepSeek-V3.2\": 1,\n\t\t\"deepseek-v4-flash\": 1,\n\t}\n\ttmpl.ModelQualityPriorities = map[string]int{\n\t\t\"glm-5.1\": 2,\n\t\t\"kimi-k2.6\": 1,\n\t}\n\treturn tmpl\n}\n\nfunc deepseekClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com/anthropic\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\nfunc deepseekOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\nfunc glmClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t}\n}\n\nfunc glmOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t// # 表示该路径已经是版本根,调用方不得再自动补 /v1。\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/paas/v4#\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/coding/paas/v4#\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n\nfunc kimiClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.moonshot.cn/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://api.moonshot.ai/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t\t{BaseURL: \"https://api.kimi.com/coding\", PlanTag: \"coding_plan\", Region: \"global\", Priority: 2},\n\t}\n}\n\nfunc kimiOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.moonshot.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://api.moonshot.ai/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t\t{BaseURL: \"https://api.kimi.com/coding/v1\", PlanTag: \"coding_plan\", Region: \"global\", Priority: 2},\n\t}\n}\n\nfunc dashScopeClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://dashscope.aliyuncs.com/apps/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://coding.dashscope.aliyuncs.com/apps/anthropic\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 2},\n\t}\n}\n\nfunc dashScopeOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://dashscope.aliyuncs.com/compatible-mode/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://coding.dashscope.aliyuncs.com/v1\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 2},\n\t}\n}\n\nfunc openCodeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://opencode.ai/zen/go/v1\", PlanTag: \"subscription\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://opencode.ai/zen/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t}\n}\n\nfunc xfYunRoutes() []ProviderRoute {\n\treturn []ProviderRoute{\n\t\t{\n\t\t\tChannelKind: \"messages\",\n\t\t\tServiceType: \"claude\",\n\t\t\tDescription: \"Claude Messages Coding Plan 入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/anthropic\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"chat\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"OpenAI Chat Completions Coding Plan 入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v2\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"responses\",\n\t\t\tServiceType: \"responses\",\n\t\t\tDescription: \"OpenAI Responses Coding Plan 原生入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v1/responses\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t}\n}\n\n// ListProviderTemplates 返回所有内置 provider 模板。\nfunc ListProviderTemplates() []ProviderTemplate {\n\tout := make([]ProviderTemplate, len(builtinProviderTemplates))\n\tcopy(out, builtinProviderTemplates)\n\treturn out\n}\n\n// GetProviderTemplate 按 providerId 查找模板,未找到返回 (nil, false)。\nfunc GetProviderTemplate(providerID string) (*ProviderTemplate, bool) {\n\tproviderID = strings.ToLower(strings.TrimSpace(providerID))\n\tfor i := range builtinProviderTemplates {\n\t\ttmpl := &builtinProviderTemplates[i]\n\t\tif strings.EqualFold(tmpl.ProviderID, providerID) {\n\t\t\treturn tmpl, true\n\t\t}\n\t\tfor _, alias := range tmpl.Aliases {\n\t\t\tif strings.EqualFold(alias, providerID) {\n\t\t\t\treturn tmpl, true\n\t\t\t}\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ModelCostMultiplierForModel 返回 provider 套餐内指定模型的相对消耗倍率。\n// 精确 ID 优先,随后按最长通配符匹配;非法、负数或非有限值视为未知。\nfunc (t *ProviderTemplate) ModelCostMultiplierForModel(modelID string) (float64, bool) {\n\tif t == nil {\n\t\treturn 0, false\n\t}\n\tmultiplier, _, ok := resolvePatternValueFold(modelID, t.ModelCostMultipliers)\n\tif !ok || multiplier < 0 || math.IsNaN(multiplier) || math.IsInf(multiplier, 0) {\n\t\treturn 0, false\n\t}\n\treturn multiplier, true\n}\n\n// ModelQualityPriorityForModel 返回 provider 已确认的同档模型能力优先级。\nfunc (t *ProviderTemplate) ModelQualityPriorityForModel(modelID string) (int, bool) {\n\tif t == nil {\n\t\treturn 0, false\n\t}\n\tpriority, _, ok := resolvePatternValueFold(modelID, t.ModelQualityPriorities)\n\tif !ok || priority <= 0 {\n\t\treturn 0, false\n\t}\n\treturn priority, true\n}\n\n// InferProviderIDFromBaseURL 仅按已知模板候选端点识别 provider。\n// 使用最长路径匹配,避免把仅承载同名模型的其他中转站误判为该渠道。\nfunc InferProviderIDFromBaseURL(baseURL string) (string, bool) {\n\ttarget, err := url.Parse(strings.TrimSuffix(strings.TrimSpace(baseURL), \"#\"))\n\tif err != nil || target.Hostname() == \"\" {\n\t\treturn \"\", false\n\t}\n\ttargetPath := strings.TrimRight(target.EscapedPath(), \"/\")\n\tbestProvider := \"\"\n\tbestPathLen := -1\n\tfor _, tmpl := range builtinProviderTemplates {\n\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\tfor _, candidate := range route.Candidates {\n\t\t\t\tcandidateURL, parseErr := url.Parse(strings.TrimSpace(candidate.BaseURL))\n\t\t\t\tif parseErr != nil || !sameURLServer(target, candidateURL) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tcandidatePath := strings.TrimRight(candidateURL.EscapedPath(), \"/\")\n\t\t\t\tif candidatePath != \"\" && targetPath != candidatePath && !strings.HasPrefix(targetPath, candidatePath+\"/\") {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif len(candidatePath) > bestPathLen {\n\t\t\t\t\tbestProvider = tmpl.ProviderID\n\t\t\t\t\tbestPathLen = len(candidatePath)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn bestProvider, bestProvider != \"\"\n}\n\n// InferProviderIDFromAPIKey 仅识别具有官方唯一格式的 API Key。\n// sk- 等共享格式无法可靠区分 provider,因此不会参与推断。\nfunc InferProviderIDFromAPIKey(apiKey string) (string, bool) {\n\tif zhipuAPIKeyPattern.MatchString(strings.TrimSpace(apiKey)) {\n\t\treturn \"glm\", true\n\t}\n\treturn \"\", false\n}\n\nfunc sameURLServer(left, right *url.URL) bool {\n\tif left == nil || right == nil || !strings.EqualFold(left.Hostname(), right.Hostname()) {\n\t\treturn false\n\t}\n\treturn effectiveURLPort(left) == effectiveURLPort(right)\n}\n\nfunc effectiveURLPort(value *url.URL) string {\n\tif value == nil {\n\t\treturn \"\"\n\t}\n\tif port := value.Port(); port != \"\" {\n\t\treturn port\n\t}\n\tswitch strings.ToLower(value.Scheme) {\n\tcase \"https\":\n\t\treturn \"443\"\n\tcase \"http\":\n\t\treturn \"80\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// AutoAddRoutes 返回 provider 快速添加时需要创建的渠道 route。\nfunc (t *ProviderTemplate) AutoAddRoutes() []ProviderRoute {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tif len(t.Routes) > 0 {\n\t\tout := make([]ProviderRoute, len(t.Routes))\n\t\tcopy(out, t.Routes)\n\t\treturn out\n\t}\n\tif t.ChannelKind == \"\" || t.ServiceType == \"\" {\n\t\treturn nil\n\t}\n\treturn []ProviderRoute{{\n\t\tChannelKind: t.ChannelKind,\n\t\tServiceType: t.ServiceType,\n\t\tCandidates: append([]ProviderCandidate(nil), t.Candidates...),\n\t}}\n}\n\n// SupportsChannelKind 判断 provider 是否可从指定渠道页发起快速添加。\nfunc (t *ProviderTemplate) SupportsChannelKind(kind string) bool {\n\tfor _, route := range t.AutoAddRoutes() {\n\t\tif route.ChannelKind == kind {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// CandidatesForKey 按 API Key 前缀返回默认 route 的候选 baseURL 顺序。\n//\n// 规则(对应用户决策):\n// 1. key 前缀命中某 PlanTag → 该 plan 的候选优先(按 Priority 升序),其余候选追加在后作为回退\n// 2. 前缀不匹配任何规则(或模板无前缀规则)→ 返回全部候选(按 Priority 升序)\n//\n// 返回的候选已按探测顺序排列,调用方依次探测,命中即用。\nfunc (t *ProviderTemplate) CandidatesForKey(apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\treturn t.CandidatesForRouteKey(ProviderRoute{Candidates: t.Candidates}, apiKey)\n}\n\n// CandidatesForRouteKey 按 API Key 前缀返回指定 route 的候选 baseURL 顺序。\nfunc (t *ProviderTemplate) CandidatesForRouteKey(route ProviderRoute, apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tmatchedPlan := \"\"\n\tfor _, rule := range t.KeyPrefixRules {\n\t\tif rule.Prefix != \"\" && strings.HasPrefix(apiKey, rule.Prefix) {\n\t\t\tmatchedPlan = rule.PlanTag\n\t\t\tbreak\n\t\t}\n\t}\n\n\tsortByPriority := func(list []ProviderCandidate) []ProviderCandidate {\n\t\tsorted := make([]ProviderCandidate, len(list))\n\t\tcopy(sorted, list)\n\t\t// 稳定插入排序(候选数量极少,无需 sort 包)\n\t\tfor i := 1; i < len(sorted); i++ {\n\t\t\tfor j := i; j > 0 && sorted[j].Priority < sorted[j-1].Priority; j-- {\n\t\t\t\tsorted[j], sorted[j-1] = sorted[j-1], sorted[j]\n\t\t\t}\n\t\t}\n\t\treturn sorted\n\t}\n\n\tif matchedPlan == \"\" {\n\t\treturn sortByPriority(route.Candidates)\n\t}\n\n\tpreferred := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfallback := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfor _, cand := range route.Candidates {\n\t\tif cand.PlanTag == matchedPlan {\n\t\t\tpreferred = append(preferred, cand)\n\t\t} else {\n\t\t\tfallback = append(fallback, cand)\n\t\t}\n\t}\n\treturn append(sortByPriority(preferred), sortByPriority(fallback)...)\n}\n\nfunc mimoClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc mimoChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc volcenginePlanClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n\nfunc volcenginePlanChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n"}, "2a8bcfc193af0d67b777323a15297d090042a68f": {"t": 1785759319.4962234, "n": 23596, "h": "eda6ed3656149dc3", "c": "package config\n\nimport (\n\t\"math\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n)\n\n// ProviderTemplate 描述一个已知 provider 的模板化添加配置。\n//\n// 设计目标:用户只需选择 provider + 输入 API Key,系统按 key 前缀选候选 baseURL,\n// 探测验证可用性后自动创建渠道,无需手填 baseURL / 选协议 / 配兼容开关。\n//\n// baseURL 按上游原生协议区分端点:Claude 请求优先走原生 Claude 入口;\n// Chat / Codex Responses 请求走 OpenAI Chat 兼容入口,由后端做协议转换。\n// ProviderTemplate 只描述来源和端点,不再承载 channel-presets 兼容开关;\n// autoManaged 渠道的模型与能力差异由后端智能调度/ModelResolver 处理。\ntype ProviderTemplate struct {\n\tProviderID string `json:\"providerId\"` // \"mimo\" / \"deepseek\" / ...\n\tAliases []string `json:\"aliases,omitempty\"`\n\tDisplayName string `json:\"displayName\"` // \"小米 MiMo\"\n\tDescription string `json:\"description\"` // key 前缀说明等\n\tChannelKind string `json:\"channelKind\"` // 默认 route 的渠道类型,兼容旧前端\n\tServiceType string `json:\"serviceType\"` // 默认 route 的服务类型,兼容旧前端\n\tOriginType string `json:\"originType\"` // official_api / official_token_plan / relay\n\tOriginTier string `json:\"originTier\"` // first / second\n\tKeyPrefixRules []KeyPrefixRule `json:\"keyPrefixRules\"` // key 前缀 → plan 判别\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 默认 route 的候选 baseURL\n\tRoutes []ProviderRoute `json:\"routes,omitempty\"`\n\t// ModelCostMultipliers 描述同一 provider 套餐内每次模型调用的相对消耗。\n\t// key 支持模型精确 ID 或通配符;值越低越省,只在质量与实测表现相同后参与选优。\n\tModelCostMultipliers map[string]float64 `json:\"modelCostMultipliers,omitempty\"`\n}\n\n// ProviderRoute 描述同一 provider 在某个 CCX 渠道协议下使用的原生上游入口。\ntype ProviderRoute struct {\n\tChannelKind string `json:\"channelKind\"` // \"messages\" / \"chat\" / \"responses\"\n\tServiceType string `json:\"serviceType\"` // \"claude\" / \"openai\" / \"responses\"\n\tDescription string `json:\"description,omitempty\"` // route 说明,仅展示/诊断\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 该 route 的候选 baseURL\n}\n\n// KeyPrefixRule 按 API Key 前缀判别 plan 类型。\ntype KeyPrefixRule struct {\n\tPrefix string `json:\"prefix\"` // \"sk-\" / \"tp-\"\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\"\n}\n\n// ProviderCandidate 一个候选 baseURL 及其归属信息。\ntype ProviderCandidate struct {\n\tBaseURL string `json:\"baseUrl\"` // 完整 baseURL,如 https://api.xiaomimimo.com/anthropic\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\" / \"\"\n\tRegion string `json:\"region\"` // \"cn\" / \"sgp\" / \"ams\" / \"global\" / \"\"\n\tPriority int `json:\"priority\"` // 同 plan 内探测优先级(数字越小越先探测)\n}\n\n// builtinProviderTemplates 编译期内置的已知 provider 模板。\n//\n// URL 来源(2026-07 联网核实官方文档):\n// - MiMo: https://mimo.mi.com/docs(按量 sk- / Token Plan tp-,TP 分 cn/sgp/ams 三区域集群)\n// - DeepSeek: https://api-docs.deepseek.com(Anthropic 兼容 /anthropic)\n// - Kimi: https://api.moonshot.ai/anthropic(全球)/ https://api.moonshot.cn/anthropic(中国)\n// - GLM: https://open.bigmodel.cn/api/anthropic(Claude)与 /api/paas/v4(OpenAI)\n// - 火山方舟: https://ark.cn-beijing.volces.com/api/plan(Agent Plan)与 /api/coding(Coding Plan)\n//\n// Claude route 的 baseURL 使用 Anthropic 兼容入口且不带 /v1(claude provider 会自动补 /v1/messages)。\n// Chat/Responses route 使用 OpenAI Chat 兼容入口,由 provider 自动补协议端点。\nvar builtinProviderTemplates = []ProviderTemplate{\n\t{\n\t\tProviderID: \"mimo\",\n\t\tDisplayName: \"小米 MiMo\",\n\t\tDescription: \"sk- 按量付费 / tp- Token Plan 订阅(Claude 走 /anthropic,Chat/Codex/Gemini 走 /v1)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"sk-\", PlanTag: \"payg\"},\n\t\t\t{Prefix: \"tp-\", PlanTag: \"token_plan\"},\n\t\t},\n\t\tCandidates: mimoClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages 原生 Anthropic 兼容入口\",\n\t\t\t\tCandidates: mimoClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"deepseek\",\n\t\tDisplayName: \"DeepSeek\",\n\t\tDescription: \"DeepSeek 官方 API(Claude Messages 与 OpenAI Chat 兼容;Responses 由 CCX 转换)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: deepseekClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages Anthropic 兼容入口\",\n\t\t\t\tCandidates: deepseekClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: deepseekOpenAICandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: deepseekOpenAICandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"volcengine\",\n\t\tAliases: []string{\"volc-ark\"},\n\t\tDisplayName: \"火山方舟 Agent/Coding Plan\",\n\t\tDescription: \"ark- 套餐推理 Key(系统自动识别套餐;模型发现需为每个 Key 绑定火山云 Access Key)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"ark-\", PlanTag: \"personal_plan\"},\n\t\t},\n\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Agent/Coding Plan Claude Messages 入口\",\n\t\t\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Agent/Coding Plan OpenAI Chat Completions 入口\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"kimi\",\n\t\tAliases: []string{\"kimi-code\"},\n\t\tDisplayName: \"Kimi (Moonshot)\",\n\t\tDescription: \"Moonshot Kimi 官方 API(按量与 Coding Plan,自动判别可用入口)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: kimiClaudeCandidates(),\n\t\tRoutes: standardProviderRoutes(\"claude\", kimiClaudeCandidates(), kimiOpenAICandidates()),\n\t},\n\t{\n\t\tProviderID: \"glm\",\n\t\tDisplayName: \"智谱 GLM\",\n\t\tDescription: \"智谱 GLM 官方 API(自动识别 id.secret Key;按量与 Coding Plan)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: glmClaudeCandidates(),\n\t\tRoutes: standardProviderRoutes(\"claude\", glmClaudeCandidates(), glmOpenAICandidates()),\n\t},\n\tcompshareProviderTemplate(),\n\tnewProviderTemplate(\n\t\t\"sensenova\",\n\t\t\"SenseNova\",\n\t\t\"商汤日日新官方 API\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://token.sensenova.cn\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://token.sensenova.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"minimax\",\n\t\t\"MiniMax\",\n\t\t\"MiniMax 官方 API\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.minimaxi.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.minimax.chat/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"dashscope\",\n\t\t\"阿里云 DashScope\",\n\t\t\"阿里云百炼按量、Coding Plan 与 Token Plan\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", dashScopeClaudeCandidates(), dashScopeOpenAICandidates()),\n\t),\n\tnewProviderTemplateWithAliases(\n\t\t\"opencode-zen\",\n\t\t[]string{\"opencode-go\"},\n\t\t\"OpenCode Zen / Go\",\n\t\t\"OpenCode 官方 Zen 按量与 Go 订阅模型网关\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"openai\", openCodeCandidates(), openCodeCandidates()),\n\t),\n\tnewProviderTemplate(\n\t\t\"tencent-lkeap\",\n\t\t\"腾讯云 TokenHub\",\n\t\t\"腾讯云大模型 Token Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/v3\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"qianfan\",\n\t\t\"百度千帆 Coding Plan\",\n\t\t\"百度智能云千帆 Coding Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://qianfan.baidubce.com/anthropic/coding\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://qianfan.baidubce.com/v2/coding#\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"xfyun\",\n\t\t\"讯飞星辰\",\n\t\t\"科大讯飞星辰 MaaS Coding Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\txfYunRoutes(),\n\t),\n\tnewProviderTemplate(\n\t\t\"openrouter\",\n\t\t\"OpenRouter\",\n\t\t\"OpenRouter 多模型聚合平台\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://openrouter.ai/api\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://openrouter.ai/api/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"modelscope\",\n\t\t\"ModelScope 魔搭\",\n\t\t\"阿里 ModelScope 官方推理服务\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api-inference.modelscope.cn\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api-inference.modelscope.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"originrouter\",\n\t\t\"极易云 OriginRouter\",\n\t\t\"极易云多模型 API 转发平台\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.easytransnote.com/coding\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.easytransnote.com/coding/v1\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n}\n\nvar zhipuAPIKeyPattern = regexp.MustCompile(`^[A-Za-z0-9]{20,}\\.[A-Za-z0-9]{10,}$`)\n\nfunc standardProviderRoutes(messagesServiceType string, messagesCandidates, openAICandidates []ProviderCandidate) []ProviderRoute {\n\tmessagesDescription := \"Claude Messages 原生 Anthropic 兼容入口\"\n\tif messagesServiceType == \"openai\" {\n\t\tmessagesDescription = \"Claude Messages 请求转换到 OpenAI Chat Completions\"\n\t}\n\treturn []ProviderRoute{\n\t\t{\n\t\t\tChannelKind: \"messages\",\n\t\t\tServiceType: messagesServiceType,\n\t\t\tDescription: messagesDescription,\n\t\t\tCandidates: messagesCandidates,\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"chat\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\tCandidates: openAICandidates,\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"responses\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\tCandidates: openAICandidates,\n\t\t},\n\t}\n}\n\nfunc newProviderTemplate(providerID, displayName, description, originType, originTier string, routes []ProviderRoute) ProviderTemplate {\n\treturn newProviderTemplateWithAliases(providerID, nil, displayName, description, originType, originTier, routes)\n}\n\nfunc newProviderTemplateWithAliases(providerID string, aliases []string, displayName, description, originType, originTier string, routes []ProviderRoute) ProviderTemplate {\n\ttmpl := ProviderTemplate{\n\t\tProviderID: providerID,\n\t\tAliases: aliases,\n\t\tDisplayName: displayName,\n\t\tDescription: description,\n\t\tOriginType: originType,\n\t\tOriginTier: originTier,\n\t\tRoutes: routes,\n\t}\n\tif len(routes) > 0 {\n\t\ttmpl.ChannelKind = routes[0].ChannelKind\n\t\ttmpl.ServiceType = routes[0].ServiceType\n\t\ttmpl.Candidates = routes[0].Candidates\n\t}\n\treturn tmpl\n}\n\nfunc compshareProviderTemplate() ProviderTemplate {\n\ttmpl := newProviderTemplate(\n\t\t\"compshare\",\n\t\t\"优云智算套餐\",\n\t\t\"UCloud 优云智算模型套餐与聚合服务\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://cp.compshare.cn\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://cp.compshare.cn/v1\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t)\n\t// 优云智算控制台的套餐消耗次数;glm-5.2 当前为限时倍率。\n\ttmpl.ModelCostMultipliers = map[string]float64{\n\t\t\"glm-5.2\": 2,\n\t\t\"glm-5.1\": 6,\n\t\t\"MiniMax-M2.7\": 2,\n\t\t\"kimi-k2.6\": 5,\n\t\t\"deepseek-ai/DeepSeek-V3.2\": 1,\n\t\t\"deepseek-v4-flash\": 1,\n\t}\n\treturn tmpl\n}\n\nfunc deepseekClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com/anthropic\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\nfunc deepseekOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\nfunc glmClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t}\n}\n\nfunc glmOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t// # 表示该路径已经是版本根,调用方不得再自动补 /v1。\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/paas/v4#\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/coding/paas/v4#\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n\nfunc kimiClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.moonshot.cn/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://api.moonshot.ai/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t\t{BaseURL: \"https://api.kimi.com/coding\", PlanTag: \"coding_plan\", Region: \"global\", Priority: 2},\n\t}\n}\n\nfunc kimiOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.moonshot.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://api.moonshot.ai/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t\t{BaseURL: \"https://api.kimi.com/coding/v1\", PlanTag: \"coding_plan\", Region: \"global\", Priority: 2},\n\t}\n}\n\nfunc dashScopeClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://dashscope.aliyuncs.com/apps/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://coding.dashscope.aliyuncs.com/apps/anthropic\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 2},\n\t}\n}\n\nfunc dashScopeOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://dashscope.aliyuncs.com/compatible-mode/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://coding.dashscope.aliyuncs.com/v1\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 2},\n\t}\n}\n\nfunc openCodeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://opencode.ai/zen/go/v1\", PlanTag: \"subscription\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://opencode.ai/zen/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t}\n}\n\nfunc xfYunRoutes() []ProviderRoute {\n\treturn []ProviderRoute{\n\t\t{\n\t\t\tChannelKind: \"messages\",\n\t\t\tServiceType: \"claude\",\n\t\t\tDescription: \"Claude Messages Coding Plan 入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/anthropic\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"chat\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"OpenAI Chat Completions Coding Plan 入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v2\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"responses\",\n\t\t\tServiceType: \"responses\",\n\t\t\tDescription: \"OpenAI Responses Coding Plan 原生入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v1/responses\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t}\n}\n\n// ListProviderTemplates 返回所有内置 provider 模板。\nfunc ListProviderTemplates() []ProviderTemplate {\n\tout := make([]ProviderTemplate, len(builtinProviderTemplates))\n\tcopy(out, builtinProviderTemplates)\n\treturn out\n}\n\n// GetProviderTemplate 按 providerId 查找模板,未找到返回 (nil, false)。\nfunc GetProviderTemplate(providerID string) (*ProviderTemplate, bool) {\n\tproviderID = strings.ToLower(strings.TrimSpace(providerID))\n\tfor i := range builtinProviderTemplates {\n\t\ttmpl := &builtinProviderTemplates[i]\n\t\tif strings.EqualFold(tmpl.ProviderID, providerID) {\n\t\t\treturn tmpl, true\n\t\t}\n\t\tfor _, alias := range tmpl.Aliases {\n\t\t\tif strings.EqualFold(alias, providerID) {\n\t\t\t\treturn tmpl, true\n\t\t\t}\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ModelCostMultiplierForModel 返回 provider 套餐内指定模型的相对消耗倍率。\n// 精确 ID 优先,随后按最长通配符匹配;非法、负数或非有限值视为未知。\nfunc (t *ProviderTemplate) ModelCostMultiplierForModel(modelID string) (float64, bool) {\n\tif t == nil {\n\t\treturn 0, false\n\t}\n\tmultiplier, _, ok := resolvePatternValueFold(modelID, t.ModelCostMultipliers)\n\tif !ok || multiplier < 0 || math.IsNaN(multiplier) || math.IsInf(multiplier, 0) {\n\t\treturn 0, false\n\t}\n\treturn multiplier, true\n}\n\n// InferProviderIDFromBaseURL 仅按已知模板候选端点识别 provider。\n// 使用最长路径匹配,避免把仅承载同名模型的其他中转站误判为该渠道。\nfunc InferProviderIDFromBaseURL(baseURL string) (string, bool) {\n\ttarget, err := url.Parse(strings.TrimSuffix(strings.TrimSpace(baseURL), \"#\"))\n\tif err != nil || target.Hostname() == \"\" {\n\t\treturn \"\", false\n\t}\n\ttargetPath := strings.TrimRight(target.EscapedPath(), \"/\")\n\tbestProvider := \"\"\n\tbestPathLen := -1\n\tfor _, tmpl := range builtinProviderTemplates {\n\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\tfor _, candidate := range route.Candidates {\n\t\t\t\tcandidateURL, parseErr := url.Parse(strings.TrimSpace(candidate.BaseURL))\n\t\t\t\tif parseErr != nil || !sameURLServer(target, candidateURL) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tcandidatePath := strings.TrimRight(candidateURL.EscapedPath(), \"/\")\n\t\t\t\tif candidatePath != \"\" && targetPath != candidatePath && !strings.HasPrefix(targetPath, candidatePath+\"/\") {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif len(candidatePath) > bestPathLen {\n\t\t\t\t\tbestProvider = tmpl.ProviderID\n\t\t\t\t\tbestPathLen = len(candidatePath)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn bestProvider, bestProvider != \"\"\n}\n\n// InferProviderIDFromAPIKey 仅识别具有官方唯一格式的 API Key。\n// sk- 等共享格式无法可靠区分 provider,因此不会参与推断。\nfunc InferProviderIDFromAPIKey(apiKey string) (string, bool) {\n\tif zhipuAPIKeyPattern.MatchString(strings.TrimSpace(apiKey)) {\n\t\treturn \"glm\", true\n\t}\n\treturn \"\", false\n}\n\nfunc sameURLServer(left, right *url.URL) bool {\n\tif left == nil || right == nil || !strings.EqualFold(left.Hostname(), right.Hostname()) {\n\t\treturn false\n\t}\n\treturn effectiveURLPort(left) == effectiveURLPort(right)\n}\n\nfunc effectiveURLPort(value *url.URL) string {\n\tif value == nil {\n\t\treturn \"\"\n\t}\n\tif port := value.Port(); port != \"\" {\n\t\treturn port\n\t}\n\tswitch strings.ToLower(value.Scheme) {\n\tcase \"https\":\n\t\treturn \"443\"\n\tcase \"http\":\n\t\treturn \"80\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// AutoAddRoutes 返回 provider 快速添加时需要创建的渠道 route。\nfunc (t *ProviderTemplate) AutoAddRoutes() []ProviderRoute {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tif len(t.Routes) > 0 {\n\t\tout := make([]ProviderRoute, len(t.Routes))\n\t\tcopy(out, t.Routes)\n\t\treturn out\n\t}\n\tif t.ChannelKind == \"\" || t.ServiceType == \"\" {\n\t\treturn nil\n\t}\n\treturn []ProviderRoute{{\n\t\tChannelKind: t.ChannelKind,\n\t\tServiceType: t.ServiceType,\n\t\tCandidates: append([]ProviderCandidate(nil), t.Candidates...),\n\t}}\n}\n\n// SupportsChannelKind 判断 provider 是否可从指定渠道页发起快速添加。\nfunc (t *ProviderTemplate) SupportsChannelKind(kind string) bool {\n\tfor _, route := range t.AutoAddRoutes() {\n\t\tif route.ChannelKind == kind {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// CandidatesForKey 按 API Key 前缀返回默认 route 的候选 baseURL 顺序。\n//\n// 规则(对应用户决策):\n// 1. key 前缀命中某 PlanTag → 该 plan 的候选优先(按 Priority 升序),其余候选追加在后作为回退\n// 2. 前缀不匹配任何规则(或模板无前缀规则)→ 返回全部候选(按 Priority 升序)\n//\n// 返回的候选已按探测顺序排列,调用方依次探测,命中即用。\nfunc (t *ProviderTemplate) CandidatesForKey(apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\treturn t.CandidatesForRouteKey(ProviderRoute{Candidates: t.Candidates}, apiKey)\n}\n\n// CandidatesForRouteKey 按 API Key 前缀返回指定 route 的候选 baseURL 顺序。\nfunc (t *ProviderTemplate) CandidatesForRouteKey(route ProviderRoute, apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tmatchedPlan := \"\"\n\tfor _, rule := range t.KeyPrefixRules {\n\t\tif rule.Prefix != \"\" && strings.HasPrefix(apiKey, rule.Prefix) {\n\t\t\tmatchedPlan = rule.PlanTag\n\t\t\tbreak\n\t\t}\n\t}\n\n\tsortByPriority := func(list []ProviderCandidate) []ProviderCandidate {\n\t\tsorted := make([]ProviderCandidate, len(list))\n\t\tcopy(sorted, list)\n\t\t// 稳定插入排序(候选数量极少,无需 sort 包)\n\t\tfor i := 1; i < len(sorted); i++ {\n\t\t\tfor j := i; j > 0 && sorted[j].Priority < sorted[j-1].Priority; j-- {\n\t\t\t\tsorted[j], sorted[j-1] = sorted[j-1], sorted[j]\n\t\t\t}\n\t\t}\n\t\treturn sorted\n\t}\n\n\tif matchedPlan == \"\" {\n\t\treturn sortByPriority(route.Candidates)\n\t}\n\n\tpreferred := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfallback := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfor _, cand := range route.Candidates {\n\t\tif cand.PlanTag == matchedPlan {\n\t\t\tpreferred = append(preferred, cand)\n\t\t} else {\n\t\t\tfallback = append(fallback, cand)\n\t\t}\n\t}\n\treturn append(sortByPriority(preferred), sortByPriority(fallback)...)\n}\n\nfunc mimoClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc mimoChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc volcenginePlanClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n\nfunc volcenginePlanChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n"}, "c37b88af3f461fcddd8d23112b865c6c08dee729": {"t": 1785759320.9029884, "n": 22574, "h": "a07bcff4ca172bb5", "c": "package config\n\nimport (\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n)\n\n// ProviderTemplate 描述一个已知 provider 的模板化添加配置。\n//\n// 设计目标:用户只需选择 provider + 输入 API Key,系统按 key 前缀选候选 baseURL,\n// 探测验证可用性后自动创建渠道,无需手填 baseURL / 选协议 / 配兼容开关。\n//\n// baseURL 按上游原生协议区分端点:Claude 请求优先走原生 Claude 入口;\n// Chat / Codex Responses 请求走 OpenAI Chat 兼容入口,由后端做协议转换。\n// ProviderTemplate 只描述来源和端点,不再承载 channel-presets 兼容开关;\n// autoManaged 渠道的模型与能力差异由后端智能调度/ModelResolver 处理。\ntype ProviderTemplate struct {\n\tProviderID string `json:\"providerId\"` // \"mimo\" / \"deepseek\" / ...\n\tAliases []string `json:\"aliases,omitempty\"`\n\tDisplayName string `json:\"displayName\"` // \"小米 MiMo\"\n\tDescription string `json:\"description\"` // key 前缀说明等\n\tChannelKind string `json:\"channelKind\"` // 默认 route 的渠道类型,兼容旧前端\n\tServiceType string `json:\"serviceType\"` // 默认 route 的服务类型,兼容旧前端\n\tOriginType string `json:\"originType\"` // official_api / official_token_plan / relay\n\tOriginTier string `json:\"originTier\"` // first / second\n\tKeyPrefixRules []KeyPrefixRule `json:\"keyPrefixRules\"` // key 前缀 → plan 判别\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 默认 route 的候选 baseURL\n\tRoutes []ProviderRoute `json:\"routes,omitempty\"`\n}\n\n// ProviderRoute 描述同一 provider 在某个 CCX 渠道协议下使用的原生上游入口。\ntype ProviderRoute struct {\n\tChannelKind string `json:\"channelKind\"` // \"messages\" / \"chat\" / \"responses\"\n\tServiceType string `json:\"serviceType\"` // \"claude\" / \"openai\" / \"responses\"\n\tDescription string `json:\"description,omitempty\"` // route 说明,仅展示/诊断\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 该 route 的候选 baseURL\n}\n\n// KeyPrefixRule 按 API Key 前缀判别 plan 类型。\ntype KeyPrefixRule struct {\n\tPrefix string `json:\"prefix\"` // \"sk-\" / \"tp-\"\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\"\n}\n\n// ProviderCandidate 一个候选 baseURL 及其归属信息。\ntype ProviderCandidate struct {\n\tBaseURL string `json:\"baseUrl\"` // 完整 baseURL,如 https://api.xiaomimimo.com/anthropic\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\" / \"\"\n\tRegion string `json:\"region\"` // \"cn\" / \"sgp\" / \"ams\" / \"global\" / \"\"\n\tPriority int `json:\"priority\"` // 同 plan 内探测优先级(数字越小越先探测)\n}\n\n// builtinProviderTemplates 编译期内置的已知 provider 模板。\n//\n// URL 来源(2026-07 联网核实官方文档):\n// - MiMo: https://mimo.mi.com/docs(按量 sk- / Token Plan tp-,TP 分 cn/sgp/ams 三区域集群)\n// - DeepSeek: https://api-docs.deepseek.com(Anthropic 兼容 /anthropic)\n// - Kimi: https://api.moonshot.ai/anthropic(全球)/ https://api.moonshot.cn/anthropic(中国)\n// - GLM: https://open.bigmodel.cn/api/anthropic(Claude)与 /api/paas/v4(OpenAI)\n// - 火山方舟: https://ark.cn-beijing.volces.com/api/plan(Agent Plan)与 /api/coding(Coding Plan)\n//\n// Claude route 的 baseURL 使用 Anthropic 兼容入口且不带 /v1(claude provider 会自动补 /v1/messages)。\n// Chat/Responses route 使用 OpenAI Chat 兼容入口,由 provider 自动补协议端点。\nvar builtinProviderTemplates = []ProviderTemplate{\n\t{\n\t\tProviderID: \"mimo\",\n\t\tDisplayName: \"小米 MiMo\",\n\t\tDescription: \"sk- 按量付费 / tp- Token Plan 订阅(Claude 走 /anthropic,Chat/Codex/Gemini 走 /v1)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"sk-\", PlanTag: \"payg\"},\n\t\t\t{Prefix: \"tp-\", PlanTag: \"token_plan\"},\n\t\t},\n\t\tCandidates: mimoClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages 原生 Anthropic 兼容入口\",\n\t\t\t\tCandidates: mimoClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"deepseek\",\n\t\tDisplayName: \"DeepSeek\",\n\t\tDescription: \"DeepSeek 官方 API(Claude Messages 与 OpenAI Chat 兼容;Responses 由 CCX 转换)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: deepseekClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages Anthropic 兼容入口\",\n\t\t\t\tCandidates: deepseekClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: deepseekOpenAICandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: deepseekOpenAICandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"volcengine\",\n\t\tAliases: []string{\"volc-ark\"},\n\t\tDisplayName: \"火山方舟 Agent/Coding Plan\",\n\t\tDescription: \"ark- 套餐推理 Key(系统自动识别套餐;模型发现需为每个 Key 绑定火山云 Access Key)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"ark-\", PlanTag: \"personal_plan\"},\n\t\t},\n\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Agent/Coding Plan Claude Messages 入口\",\n\t\t\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Agent/Coding Plan OpenAI Chat Completions 入口\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"kimi\",\n\t\tAliases: []string{\"kimi-code\"},\n\t\tDisplayName: \"Kimi (Moonshot)\",\n\t\tDescription: \"Moonshot Kimi 官方 API(按量与 Coding Plan,自动判别可用入口)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: kimiClaudeCandidates(),\n\t\tRoutes: standardProviderRoutes(\"claude\", kimiClaudeCandidates(), kimiOpenAICandidates()),\n\t},\n\t{\n\t\tProviderID: \"glm\",\n\t\tDisplayName: \"智谱 GLM\",\n\t\tDescription: \"智谱 GLM 官方 API(自动识别 id.secret Key;按量与 Coding Plan)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: glmClaudeCandidates(),\n\t\tRoutes: standardProviderRoutes(\"claude\", glmClaudeCandidates(), glmOpenAICandidates()),\n\t},\n\tnewProviderTemplate(\n\t\t\"compshare\",\n\t\t\"优云智算套餐\",\n\t\t\"UCloud 优云智算模型套餐与聚合服务\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://cp.compshare.cn\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://cp.compshare.cn/v1\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"sensenova\",\n\t\t\"SenseNova\",\n\t\t\"商汤日日新官方 API\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://token.sensenova.cn\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://token.sensenova.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"minimax\",\n\t\t\"MiniMax\",\n\t\t\"MiniMax 官方 API\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.minimaxi.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.minimax.chat/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"dashscope\",\n\t\t\"阿里云 DashScope\",\n\t\t\"阿里云百炼按量、Coding Plan 与 Token Plan\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", dashScopeClaudeCandidates(), dashScopeOpenAICandidates()),\n\t),\n\tnewProviderTemplateWithAliases(\n\t\t\"opencode-zen\",\n\t\t[]string{\"opencode-go\"},\n\t\t\"OpenCode Zen / Go\",\n\t\t\"OpenCode 官方 Zen 按量与 Go 订阅模型网关\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"openai\", openCodeCandidates(), openCodeCandidates()),\n\t),\n\tnewProviderTemplate(\n\t\t\"tencent-lkeap\",\n\t\t\"腾讯云 TokenHub\",\n\t\t\"腾讯云大模型 Token Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/v3\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"qianfan\",\n\t\t\"百度千帆 Coding Plan\",\n\t\t\"百度智能云千帆 Coding Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://qianfan.baidubce.com/anthropic/coding\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://qianfan.baidubce.com/v2/coding#\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"xfyun\",\n\t\t\"讯飞星辰\",\n\t\t\"科大讯飞星辰 MaaS Coding Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\txfYunRoutes(),\n\t),\n\tnewProviderTemplate(\n\t\t\"openrouter\",\n\t\t\"OpenRouter\",\n\t\t\"OpenRouter 多模型聚合平台\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://openrouter.ai/api\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://openrouter.ai/api/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"modelscope\",\n\t\t\"ModelScope 魔搭\",\n\t\t\"阿里 ModelScope 官方推理服务\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api-inference.modelscope.cn\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api-inference.modelscope.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"originrouter\",\n\t\t\"极易云 OriginRouter\",\n\t\t\"极易云多模型 API 转发平台\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.easytransnote.com/coding\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.easytransnote.com/coding/v1\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n}\n\nvar zhipuAPIKeyPattern = regexp.MustCompile(`^[A-Za-z0-9]{20,}\\.[A-Za-z0-9]{10,}$`)\n\nfunc standardProviderRoutes(messagesServiceType string, messagesCandidates, openAICandidates []ProviderCandidate) []ProviderRoute {\n\tmessagesDescription := \"Claude Messages 原生 Anthropic 兼容入口\"\n\tif messagesServiceType == \"openai\" {\n\t\tmessagesDescription = \"Claude Messages 请求转换到 OpenAI Chat Completions\"\n\t}\n\treturn []ProviderRoute{\n\t\t{\n\t\t\tChannelKind: \"messages\",\n\t\t\tServiceType: messagesServiceType,\n\t\t\tDescription: messagesDescription,\n\t\t\tCandidates: messagesCandidates,\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"chat\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\tCandidates: openAICandidates,\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"responses\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\tCandidates: openAICandidates,\n\t\t},\n\t}\n}\n\nfunc newProviderTemplate(providerID, displayName, description, originType, originTier string, routes []ProviderRoute) ProviderTemplate {\n\treturn newProviderTemplateWithAliases(providerID, nil, displayName, description, originType, originTier, routes)\n}\n\nfunc newProviderTemplateWithAliases(providerID string, aliases []string, displayName, description, originType, originTier string, routes []ProviderRoute) ProviderTemplate {\n\ttmpl := ProviderTemplate{\n\t\tProviderID: providerID,\n\t\tAliases: aliases,\n\t\tDisplayName: displayName,\n\t\tDescription: description,\n\t\tOriginType: originType,\n\t\tOriginTier: originTier,\n\t\tRoutes: routes,\n\t}\n\tif len(routes) > 0 {\n\t\ttmpl.ChannelKind = routes[0].ChannelKind\n\t\ttmpl.ServiceType = routes[0].ServiceType\n\t\ttmpl.Candidates = routes[0].Candidates\n\t}\n\treturn tmpl\n}\n\nfunc deepseekClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com/anthropic\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\nfunc deepseekOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\nfunc glmClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t}\n}\n\nfunc glmOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t// # 表示该路径已经是版本根,调用方不得再自动补 /v1。\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/paas/v4#\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/coding/paas/v4#\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n\nfunc kimiClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.moonshot.cn/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://api.moonshot.ai/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t\t{BaseURL: \"https://api.kimi.com/coding\", PlanTag: \"coding_plan\", Region: \"global\", Priority: 2},\n\t}\n}\n\nfunc kimiOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.moonshot.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://api.moonshot.ai/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t\t{BaseURL: \"https://api.kimi.com/coding/v1\", PlanTag: \"coding_plan\", Region: \"global\", Priority: 2},\n\t}\n}\n\nfunc dashScopeClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://dashscope.aliyuncs.com/apps/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://coding.dashscope.aliyuncs.com/apps/anthropic\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 2},\n\t}\n}\n\nfunc dashScopeOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://dashscope.aliyuncs.com/compatible-mode/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://coding.dashscope.aliyuncs.com/v1\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 2},\n\t}\n}\n\nfunc openCodeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://opencode.ai/zen/go/v1\", PlanTag: \"subscription\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://opencode.ai/zen/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t}\n}\n\nfunc xfYunRoutes() []ProviderRoute {\n\treturn []ProviderRoute{\n\t\t{\n\t\t\tChannelKind: \"messages\",\n\t\t\tServiceType: \"claude\",\n\t\t\tDescription: \"Claude Messages Coding Plan 入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/anthropic\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"chat\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"OpenAI Chat Completions Coding Plan 入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v2\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"responses\",\n\t\t\tServiceType: \"responses\",\n\t\t\tDescription: \"OpenAI Responses Coding Plan 原生入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v1/responses\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t}\n}\n\n// ListProviderTemplates 返回所有内置 provider 模板。\nfunc ListProviderTemplates() []ProviderTemplate {\n\tout := make([]ProviderTemplate, len(builtinProviderTemplates))\n\tcopy(out, builtinProviderTemplates)\n\treturn out\n}\n\n// GetProviderTemplate 按 providerId 查找模板,未找到返回 (nil, false)。\nfunc GetProviderTemplate(providerID string) (*ProviderTemplate, bool) {\n\tproviderID = strings.ToLower(strings.TrimSpace(providerID))\n\tfor i := range builtinProviderTemplates {\n\t\ttmpl := &builtinProviderTemplates[i]\n\t\tif strings.EqualFold(tmpl.ProviderID, providerID) {\n\t\t\treturn tmpl, true\n\t\t}\n\t\tfor _, alias := range tmpl.Aliases {\n\t\t\tif strings.EqualFold(alias, providerID) {\n\t\t\t\treturn tmpl, true\n\t\t\t}\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// InferProviderIDFromBaseURL 仅按已知模板候选端点识别 provider。\n// 使用最长路径匹配,避免把仅承载同名模型的其他中转站误判为该渠道。\nfunc InferProviderIDFromBaseURL(baseURL string) (string, bool) {\n\ttarget, err := url.Parse(strings.TrimSuffix(strings.TrimSpace(baseURL), \"#\"))\n\tif err != nil || target.Hostname() == \"\" {\n\t\treturn \"\", false\n\t}\n\ttargetPath := strings.TrimRight(target.EscapedPath(), \"/\")\n\tbestProvider := \"\"\n\tbestPathLen := -1\n\tfor _, tmpl := range builtinProviderTemplates {\n\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\tfor _, candidate := range route.Candidates {\n\t\t\t\tcandidateURL, parseErr := url.Parse(strings.TrimSpace(candidate.BaseURL))\n\t\t\t\tif parseErr != nil || !sameURLServer(target, candidateURL) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tcandidatePath := strings.TrimRight(candidateURL.EscapedPath(), \"/\")\n\t\t\t\tif candidatePath != \"\" && targetPath != candidatePath && !strings.HasPrefix(targetPath, candidatePath+\"/\") {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif len(candidatePath) > bestPathLen {\n\t\t\t\t\tbestProvider = tmpl.ProviderID\n\t\t\t\t\tbestPathLen = len(candidatePath)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn bestProvider, bestProvider != \"\"\n}\n\n// InferProviderIDFromAPIKey 仅识别具有官方唯一格式的 API Key。\n// sk- 等共享格式无法可靠区分 provider,因此不会参与推断。\nfunc InferProviderIDFromAPIKey(apiKey string) (string, bool) {\n\tif zhipuAPIKeyPattern.MatchString(strings.TrimSpace(apiKey)) {\n\t\treturn \"glm\", true\n\t}\n\treturn \"\", false\n}\n\nfunc sameURLServer(left, right *url.URL) bool {\n\tif left == nil || right == nil || !strings.EqualFold(left.Hostname(), right.Hostname()) {\n\t\treturn false\n\t}\n\treturn effectiveURLPort(left) == effectiveURLPort(right)\n}\n\nfunc effectiveURLPort(value *url.URL) string {\n\tif value == nil {\n\t\treturn \"\"\n\t}\n\tif port := value.Port(); port != \"\" {\n\t\treturn port\n\t}\n\tswitch strings.ToLower(value.Scheme) {\n\tcase \"https\":\n\t\treturn \"443\"\n\tcase \"http\":\n\t\treturn \"80\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// AutoAddRoutes 返回 provider 快速添加时需要创建的渠道 route。\nfunc (t *ProviderTemplate) AutoAddRoutes() []ProviderRoute {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tif len(t.Routes) > 0 {\n\t\tout := make([]ProviderRoute, len(t.Routes))\n\t\tcopy(out, t.Routes)\n\t\treturn out\n\t}\n\tif t.ChannelKind == \"\" || t.ServiceType == \"\" {\n\t\treturn nil\n\t}\n\treturn []ProviderRoute{{\n\t\tChannelKind: t.ChannelKind,\n\t\tServiceType: t.ServiceType,\n\t\tCandidates: append([]ProviderCandidate(nil), t.Candidates...),\n\t}}\n}\n\n// SupportsChannelKind 判断 provider 是否可从指定渠道页发起快速添加。\nfunc (t *ProviderTemplate) SupportsChannelKind(kind string) bool {\n\tfor _, route := range t.AutoAddRoutes() {\n\t\tif route.ChannelKind == kind {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// CandidatesForKey 按 API Key 前缀返回默认 route 的候选 baseURL 顺序。\n//\n// 规则(对应用户决策):\n// 1. key 前缀命中某 PlanTag → 该 plan 的候选优先(按 Priority 升序),其余候选追加在后作为回退\n// 2. 前缀不匹配任何规则(或模板无前缀规则)→ 返回全部候选(按 Priority 升序)\n//\n// 返回的候选已按探测顺序排列,调用方依次探测,命中即用。\nfunc (t *ProviderTemplate) CandidatesForKey(apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\treturn t.CandidatesForRouteKey(ProviderRoute{Candidates: t.Candidates}, apiKey)\n}\n\n// CandidatesForRouteKey 按 API Key 前缀返回指定 route 的候选 baseURL 顺序。\nfunc (t *ProviderTemplate) CandidatesForRouteKey(route ProviderRoute, apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tmatchedPlan := \"\"\n\tfor _, rule := range t.KeyPrefixRules {\n\t\tif rule.Prefix != \"\" && strings.HasPrefix(apiKey, rule.Prefix) {\n\t\t\tmatchedPlan = rule.PlanTag\n\t\t\tbreak\n\t\t}\n\t}\n\n\tsortByPriority := func(list []ProviderCandidate) []ProviderCandidate {\n\t\tsorted := make([]ProviderCandidate, len(list))\n\t\tcopy(sorted, list)\n\t\t// 稳定插入排序(候选数量极少,无需 sort 包)\n\t\tfor i := 1; i < len(sorted); i++ {\n\t\t\tfor j := i; j > 0 && sorted[j].Priority < sorted[j-1].Priority; j-- {\n\t\t\t\tsorted[j], sorted[j-1] = sorted[j-1], sorted[j]\n\t\t\t}\n\t\t}\n\t\treturn sorted\n\t}\n\n\tif matchedPlan == \"\" {\n\t\treturn sortByPriority(route.Candidates)\n\t}\n\n\tpreferred := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfallback := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfor _, cand := range route.Candidates {\n\t\tif cand.PlanTag == matchedPlan {\n\t\t\tpreferred = append(preferred, cand)\n\t\t} else {\n\t\t\tfallback = append(fallback, cand)\n\t\t}\n\t}\n\treturn append(sortByPriority(preferred), sortByPriority(fallback)...)\n}\n\nfunc mimoClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc mimoChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc volcenginePlanClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n\nfunc volcenginePlanChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n"}, "5743084bf3f7dc6abffbf9b41b81ce80f22d07fa": {"t": 1785759322.3603873, "n": 14459, "h": "9e4f5b9753a0453a", "c": "package config\n\nimport (\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n)\n\n// ProviderTemplate 描述一个官方 provider 的模板化添加配置。\n//\n// 设计目标:用户只需选择 provider + 输入 API Key,系统按 key 前缀选候选 baseURL,\n// 探测验证可用性后自动创建渠道,无需手填 baseURL / 选协议 / 配兼容开关。\n//\n// baseURL 按上游原生协议区分端点:Claude 请求优先走原生 Claude 入口;\n// Chat / Codex Responses 请求走 OpenAI Chat 兼容入口,由后端做协议转换。\n// ProviderTemplate 只描述来源和端点,不再承载 channel-presets 兼容开关;\n// autoManaged 渠道的模型与能力差异由后端智能调度/ModelResolver 处理。\ntype ProviderTemplate struct {\n\tProviderID string `json:\"providerId\"` // \"mimo\" / \"deepseek\" / ...\n\tDisplayName string `json:\"displayName\"` // \"小米 MiMo\"\n\tDescription string `json:\"description\"` // key 前缀说明等\n\tChannelKind string `json:\"channelKind\"` // 默认 route 的渠道类型,兼容旧前端\n\tServiceType string `json:\"serviceType\"` // 默认 route 的服务类型,兼容旧前端\n\tOriginType string `json:\"originType\"` // \"official_api\"\n\tOriginTier string `json:\"originTier\"` // \"first\"\n\tKeyPrefixRules []KeyPrefixRule `json:\"keyPrefixRules\"` // key 前缀 → plan 判别\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 默认 route 的候选 baseURL\n\tRoutes []ProviderRoute `json:\"routes,omitempty\"`\n}\n\n// ProviderRoute 描述同一 provider 在某个 CCX 渠道协议下使用的原生上游入口。\ntype ProviderRoute struct {\n\tChannelKind string `json:\"channelKind\"` // \"messages\" / \"chat\" / \"responses\"\n\tServiceType string `json:\"serviceType\"` // \"claude\" / \"openai\" / \"responses\"\n\tDescription string `json:\"description,omitempty\"` // route 说明,仅展示/诊断\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 该 route 的候选 baseURL\n}\n\n// KeyPrefixRule 按 API Key 前缀判别 plan 类型。\ntype KeyPrefixRule struct {\n\tPrefix string `json:\"prefix\"` // \"sk-\" / \"tp-\"\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\"\n}\n\n// ProviderCandidate 一个候选 baseURL 及其归属信息。\ntype ProviderCandidate struct {\n\tBaseURL string `json:\"baseUrl\"` // 完整 baseURL,如 https://api.xiaomimimo.com/anthropic\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\" / \"\"\n\tRegion string `json:\"region\"` // \"cn\" / \"sgp\" / \"ams\" / \"global\" / \"\"\n\tPriority int `json:\"priority\"` // 同 plan 内探测优先级(数字越小越先探测)\n}\n\n// builtinProviderTemplates 编译期内置的官方 provider 模板。\n//\n// URL 来源(2026-07 联网核实官方文档):\n// - MiMo: https://mimo.mi.com/docs(按量 sk- / Token Plan tp-,TP 分 cn/sgp/ams 三区域集群)\n// - DeepSeek: https://api-docs.deepseek.com(Anthropic 兼容 /anthropic)\n// - Kimi: https://api.moonshot.ai/anthropic(全球)/ https://api.moonshot.cn/anthropic(中国)\n// - GLM: https://open.bigmodel.cn/api/anthropic(Claude)与 /api/paas/v4(OpenAI)\n// - 火山方舟: https://ark.cn-beijing.volces.com/api/plan(Agent Plan)与 /api/coding(Coding Plan)\n//\n// Claude route 的 baseURL 使用 Anthropic 兼容入口且不带 /v1(claude provider 会自动补 /v1/messages)。\n// Chat/Responses/Gemini route 使用 OpenAI Chat 兼容入口 /v1(provider 自动拼 /chat/completions)。\nvar builtinProviderTemplates = []ProviderTemplate{\n\t{\n\t\tProviderID: \"mimo\",\n\t\tDisplayName: \"小米 MiMo\",\n\t\tDescription: \"sk- 按量付费 / tp- Token Plan 订阅(Claude 走 /anthropic,Chat/Codex/Gemini 走 /v1)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"sk-\", PlanTag: \"payg\"},\n\t\t\t{Prefix: \"tp-\", PlanTag: \"token_plan\"},\n\t\t},\n\t\tCandidates: mimoClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages 原生 Anthropic 兼容入口\",\n\t\t\t\tCandidates: mimoClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"deepseek\",\n\t\tDisplayName: \"DeepSeek\",\n\t\tDescription: \"DeepSeek 官方 API(Claude Messages 与 OpenAI Chat 兼容;Responses 由 CCX 转换)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: deepseekClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages Anthropic 兼容入口\",\n\t\t\t\tCandidates: deepseekClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: deepseekOpenAICandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: deepseekOpenAICandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"volcengine\",\n\t\tDisplayName: \"火山方舟 Agent/Coding Plan\",\n\t\tDescription: \"ark- 套餐推理 Key(系统自动识别套餐;模型发现需为每个 Key 绑定火山云 Access Key)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"ark-\", PlanTag: \"personal_plan\"},\n\t\t},\n\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Agent/Coding Plan Claude Messages 入口\",\n\t\t\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Agent/Coding Plan OpenAI Chat Completions 入口\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"kimi\",\n\t\tDisplayName: \"Kimi (Moonshot)\",\n\t\tDescription: \"Moonshot Kimi 官方 API(Anthropic 兼容,自动判别全球/中国节点)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.moonshot.cn/anthropic\", PlanTag: \"\", Region: \"cn\", Priority: 0},\n\t\t\t{BaseURL: \"https://api.moonshot.ai/anthropic\", PlanTag: \"\", Region: \"global\", Priority: 1},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"glm\",\n\t\tDisplayName: \"智谱 GLM\",\n\t\tDescription: \"智谱 GLM 官方按量 API(自动识别 id.secret Key;Claude 与 OpenAI Chat 兼容)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: glmClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages 原生 Anthropic 兼容入口\",\n\t\t\t\tCandidates: glmClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: glmOpenAICandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: glmOpenAICandidates(),\n\t\t\t},\n\t\t},\n\t},\n}\n\nvar zhipuAPIKeyPattern = regexp.MustCompile(`^[A-Za-z0-9]{20,}\\.[A-Za-z0-9]{10,}$`)\n\nfunc deepseekClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com/anthropic\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\nfunc deepseekOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\nfunc glmClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t}\n}\n\nfunc glmOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t// # 表示该路径已经是版本根,调用方不得再自动补 /v1。\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/paas/v4#\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t}\n}\n\n// ListProviderTemplates 返回所有内置 provider 模板。\nfunc ListProviderTemplates() []ProviderTemplate {\n\tout := make([]ProviderTemplate, len(builtinProviderTemplates))\n\tcopy(out, builtinProviderTemplates)\n\treturn out\n}\n\n// GetProviderTemplate 按 providerId 查找模板,未找到返回 (nil, false)。\nfunc GetProviderTemplate(providerID string) (*ProviderTemplate, bool) {\n\tfor i := range builtinProviderTemplates {\n\t\tif builtinProviderTemplates[i].ProviderID == providerID {\n\t\t\treturn &builtinProviderTemplates[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// InferProviderIDFromBaseURL 仅按官方模板候选端点识别 provider。\n// 使用最长路径匹配,避免把仅承载同名模型的第三方 relay 误判为官方渠道。\nfunc InferProviderIDFromBaseURL(baseURL string) (string, bool) {\n\ttarget, err := url.Parse(strings.TrimSuffix(strings.TrimSpace(baseURL), \"#\"))\n\tif err != nil || target.Hostname() == \"\" {\n\t\treturn \"\", false\n\t}\n\ttargetPath := strings.TrimRight(target.EscapedPath(), \"/\")\n\tbestProvider := \"\"\n\tbestPathLen := -1\n\tfor _, tmpl := range builtinProviderTemplates {\n\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\tfor _, candidate := range route.Candidates {\n\t\t\t\tcandidateURL, parseErr := url.Parse(strings.TrimSpace(candidate.BaseURL))\n\t\t\t\tif parseErr != nil || !sameURLServer(target, candidateURL) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tcandidatePath := strings.TrimRight(candidateURL.EscapedPath(), \"/\")\n\t\t\t\tif candidatePath != \"\" && targetPath != candidatePath && !strings.HasPrefix(targetPath, candidatePath+\"/\") {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif len(candidatePath) > bestPathLen {\n\t\t\t\t\tbestProvider = tmpl.ProviderID\n\t\t\t\t\tbestPathLen = len(candidatePath)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn bestProvider, bestProvider != \"\"\n}\n\n// InferProviderIDFromAPIKey 仅识别具有官方唯一格式的 API Key。\n// sk- 等共享格式无法可靠区分 provider,因此不会参与推断。\nfunc InferProviderIDFromAPIKey(apiKey string) (string, bool) {\n\tif zhipuAPIKeyPattern.MatchString(strings.TrimSpace(apiKey)) {\n\t\treturn \"glm\", true\n\t}\n\treturn \"\", false\n}\n\nfunc sameURLServer(left, right *url.URL) bool {\n\tif left == nil || right == nil || !strings.EqualFold(left.Hostname(), right.Hostname()) {\n\t\treturn false\n\t}\n\treturn effectiveURLPort(left) == effectiveURLPort(right)\n}\n\nfunc effectiveURLPort(value *url.URL) string {\n\tif value == nil {\n\t\treturn \"\"\n\t}\n\tif port := value.Port(); port != \"\" {\n\t\treturn port\n\t}\n\tswitch strings.ToLower(value.Scheme) {\n\tcase \"https\":\n\t\treturn \"443\"\n\tcase \"http\":\n\t\treturn \"80\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// AutoAddRoutes 返回 provider 快速添加时需要创建的渠道 route。\nfunc (t *ProviderTemplate) AutoAddRoutes() []ProviderRoute {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tif len(t.Routes) > 0 {\n\t\tout := make([]ProviderRoute, len(t.Routes))\n\t\tcopy(out, t.Routes)\n\t\treturn out\n\t}\n\tif t.ChannelKind == \"\" || t.ServiceType == \"\" {\n\t\treturn nil\n\t}\n\treturn []ProviderRoute{{\n\t\tChannelKind: t.ChannelKind,\n\t\tServiceType: t.ServiceType,\n\t\tCandidates: append([]ProviderCandidate(nil), t.Candidates...),\n\t}}\n}\n\n// SupportsChannelKind 判断 provider 是否可从指定渠道页发起快速添加。\nfunc (t *ProviderTemplate) SupportsChannelKind(kind string) bool {\n\tfor _, route := range t.AutoAddRoutes() {\n\t\tif route.ChannelKind == kind {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// CandidatesForKey 按 API Key 前缀返回默认 route 的候选 baseURL 顺序。\n//\n// 规则(对应用户决策):\n// 1. key 前缀命中某 PlanTag → 该 plan 的候选优先(按 Priority 升序),其余候选追加在后作为回退\n// 2. 前缀不匹配任何规则(或模板无前缀规则)→ 返回全部候选(按 Priority 升序)\n//\n// 返回的候选已按探测顺序排列,调用方依次探测,命中即用。\nfunc (t *ProviderTemplate) CandidatesForKey(apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\treturn t.CandidatesForRouteKey(ProviderRoute{Candidates: t.Candidates}, apiKey)\n}\n\n// CandidatesForRouteKey 按 API Key 前缀返回指定 route 的候选 baseURL 顺序。\nfunc (t *ProviderTemplate) CandidatesForRouteKey(route ProviderRoute, apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tmatchedPlan := \"\"\n\tfor _, rule := range t.KeyPrefixRules {\n\t\tif rule.Prefix != \"\" && strings.HasPrefix(apiKey, rule.Prefix) {\n\t\t\tmatchedPlan = rule.PlanTag\n\t\t\tbreak\n\t\t}\n\t}\n\n\tsortByPriority := func(list []ProviderCandidate) []ProviderCandidate {\n\t\tsorted := make([]ProviderCandidate, len(list))\n\t\tcopy(sorted, list)\n\t\t// 稳定插入排序(候选数量极少,无需 sort 包)\n\t\tfor i := 1; i < len(sorted); i++ {\n\t\t\tfor j := i; j > 0 && sorted[j].Priority < sorted[j-1].Priority; j-- {\n\t\t\t\tsorted[j], sorted[j-1] = sorted[j-1], sorted[j]\n\t\t\t}\n\t\t}\n\t\treturn sorted\n\t}\n\n\tif matchedPlan == \"\" {\n\t\treturn sortByPriority(route.Candidates)\n\t}\n\n\tpreferred := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfallback := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfor _, cand := range route.Candidates {\n\t\tif cand.PlanTag == matchedPlan {\n\t\t\tpreferred = append(preferred, cand)\n\t\t} else {\n\t\t\tfallback = append(fallback, cand)\n\t\t}\n\t}\n\treturn append(sortByPriority(preferred), sortByPriority(fallback)...)\n}\n\nfunc mimoClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc mimoChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc volcenginePlanClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n\nfunc volcenginePlanChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n"}, "5d2ea67008b8062dd3b4f7104a16827e50c393b3": {"t": 1785759324.3754303, "n": 13227, "h": "bf0c569303f2063e", "c": "package config\n\nimport (\n\t\"net/url\"\n\t\"strings\"\n)\n\n// ProviderTemplate 描述一个官方 provider 的模板化添加配置。\n//\n// 设计目标:用户只需选择 provider + 输入 API Key,系统按 key 前缀选候选 baseURL,\n// 探测验证可用性后自动创建渠道,无需手填 baseURL / 选协议 / 配兼容开关。\n//\n// baseURL 按上游原生协议区分端点:Claude 请求优先走原生 Claude 入口;\n// Chat / Codex Responses 请求走 OpenAI Chat 兼容入口,由后端做协议转换。\n// ProviderTemplate 只描述来源和端点,不再承载 channel-presets 兼容开关;\n// autoManaged 渠道的模型与能力差异由后端智能调度/ModelResolver 处理。\ntype ProviderTemplate struct {\n\tProviderID string `json:\"providerId\"` // \"mimo\" / \"deepseek\" / ...\n\tDisplayName string `json:\"displayName\"` // \"小米 MiMo\"\n\tDescription string `json:\"description\"` // key 前缀说明等\n\tChannelKind string `json:\"channelKind\"` // 默认 route 的渠道类型,兼容旧前端\n\tServiceType string `json:\"serviceType\"` // 默认 route 的服务类型,兼容旧前端\n\tOriginType string `json:\"originType\"` // \"official_api\"\n\tOriginTier string `json:\"originTier\"` // \"first\"\n\tKeyPrefixRules []KeyPrefixRule `json:\"keyPrefixRules\"` // key 前缀 → plan 判别\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 默认 route 的候选 baseURL\n\tRoutes []ProviderRoute `json:\"routes,omitempty\"`\n}\n\n// ProviderRoute 描述同一 provider 在某个 CCX 渠道协议下使用的原生上游入口。\ntype ProviderRoute struct {\n\tChannelKind string `json:\"channelKind\"` // \"messages\" / \"chat\" / \"responses\"\n\tServiceType string `json:\"serviceType\"` // \"claude\" / \"openai\" / \"responses\"\n\tDescription string `json:\"description,omitempty\"` // route 说明,仅展示/诊断\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 该 route 的候选 baseURL\n}\n\n// KeyPrefixRule 按 API Key 前缀判别 plan 类型。\ntype KeyPrefixRule struct {\n\tPrefix string `json:\"prefix\"` // \"sk-\" / \"tp-\"\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\"\n}\n\n// ProviderCandidate 一个候选 baseURL 及其归属信息。\ntype ProviderCandidate struct {\n\tBaseURL string `json:\"baseUrl\"` // 完整 baseURL,如 https://api.xiaomimimo.com/anthropic\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\" / \"\"\n\tRegion string `json:\"region\"` // \"cn\" / \"sgp\" / \"ams\" / \"global\" / \"\"\n\tPriority int `json:\"priority\"` // 同 plan 内探测优先级(数字越小越先探测)\n}\n\n// builtinProviderTemplates 编译期内置的官方 provider 模板。\n//\n// URL 来源(2026-07 联网核实官方文档):\n// - MiMo: https://mimo.mi.com/docs(按量 sk- / Token Plan tp-,TP 分 cn/sgp/ams 三区域集群)\n// - DeepSeek: https://api-docs.deepseek.com(Anthropic 兼容 /anthropic)\n// - Kimi: https://api.moonshot.ai/anthropic(全球)/ https://api.moonshot.cn/anthropic(中国)\n// - GLM: https://open.bigmodel.cn/api/anthropic\n// - 火山方舟: https://ark.cn-beijing.volces.com/api/plan(Agent Plan)与 /api/coding(Coding Plan)\n//\n// Claude route 的 baseURL 使用 Anthropic 兼容入口且不带 /v1(claude provider 会自动补 /v1/messages)。\n// Chat/Responses/Gemini route 使用 OpenAI Chat 兼容入口 /v1(provider 自动拼 /chat/completions)。\nvar builtinProviderTemplates = []ProviderTemplate{\n\t{\n\t\tProviderID: \"mimo\",\n\t\tDisplayName: \"小米 MiMo\",\n\t\tDescription: \"sk- 按量付费 / tp- Token Plan 订阅(Claude 走 /anthropic,Chat/Codex/Gemini 走 /v1)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"sk-\", PlanTag: \"payg\"},\n\t\t\t{Prefix: \"tp-\", PlanTag: \"token_plan\"},\n\t\t},\n\t\tCandidates: mimoClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages 原生 Anthropic 兼容入口\",\n\t\t\t\tCandidates: mimoClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"deepseek\",\n\t\tDisplayName: \"DeepSeek\",\n\t\tDescription: \"DeepSeek 官方 API(Claude Messages 与 OpenAI Chat 兼容;Responses 由 CCX 转换)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: deepseekClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages Anthropic 兼容入口\",\n\t\t\t\tCandidates: deepseekClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: deepseekOpenAICandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: deepseekOpenAICandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"volcengine\",\n\t\tDisplayName: \"火山方舟 Agent/Coding Plan\",\n\t\tDescription: \"ark- 套餐推理 Key(系统自动识别套餐;模型发现需为每个 Key 绑定火山云 Access Key)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"ark-\", PlanTag: \"personal_plan\"},\n\t\t},\n\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Agent/Coding Plan Claude Messages 入口\",\n\t\t\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Agent/Coding Plan OpenAI Chat Completions 入口\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"kimi\",\n\t\tDisplayName: \"Kimi (Moonshot)\",\n\t\tDescription: \"Moonshot Kimi 官方 API(Anthropic 兼容,自动判别全球/中国节点)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.moonshot.cn/anthropic\", PlanTag: \"\", Region: \"cn\", Priority: 0},\n\t\t\t{BaseURL: \"https://api.moonshot.ai/anthropic\", PlanTag: \"\", Region: \"global\", Priority: 1},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"glm\",\n\t\tDisplayName: \"智谱 GLM\",\n\t\tDescription: \"智谱 GLM 官方 API(Anthropic 兼容协议)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: []ProviderCandidate{\n\t\t\t{BaseURL: \"https://open.bigmodel.cn/api/anthropic\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t\t},\n\t},\n}\n\nfunc deepseekClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com/anthropic\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\nfunc deepseekOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\n// ListProviderTemplates 返回所有内置 provider 模板。\nfunc ListProviderTemplates() []ProviderTemplate {\n\tout := make([]ProviderTemplate, len(builtinProviderTemplates))\n\tcopy(out, builtinProviderTemplates)\n\treturn out\n}\n\n// GetProviderTemplate 按 providerId 查找模板,未找到返回 (nil, false)。\nfunc GetProviderTemplate(providerID string) (*ProviderTemplate, bool) {\n\tfor i := range builtinProviderTemplates {\n\t\tif builtinProviderTemplates[i].ProviderID == providerID {\n\t\t\treturn &builtinProviderTemplates[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// InferProviderIDFromBaseURL 仅按官方模板候选端点识别 provider。\n// 使用最长路径匹配,避免把仅承载同名模型的第三方 relay 误判为官方渠道。\nfunc InferProviderIDFromBaseURL(baseURL string) (string, bool) {\n\ttarget, err := url.Parse(strings.TrimSuffix(strings.TrimSpace(baseURL), \"#\"))\n\tif err != nil || target.Hostname() == \"\" {\n\t\treturn \"\", false\n\t}\n\ttargetPath := strings.TrimRight(target.EscapedPath(), \"/\")\n\tbestProvider := \"\"\n\tbestPathLen := -1\n\tfor _, tmpl := range builtinProviderTemplates {\n\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\tfor _, candidate := range route.Candidates {\n\t\t\t\tcandidateURL, parseErr := url.Parse(strings.TrimSpace(candidate.BaseURL))\n\t\t\t\tif parseErr != nil || !sameURLServer(target, candidateURL) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tcandidatePath := strings.TrimRight(candidateURL.EscapedPath(), \"/\")\n\t\t\t\tif candidatePath != \"\" && targetPath != candidatePath && !strings.HasPrefix(targetPath, candidatePath+\"/\") {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif len(candidatePath) > bestPathLen {\n\t\t\t\t\tbestProvider = tmpl.ProviderID\n\t\t\t\t\tbestPathLen = len(candidatePath)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn bestProvider, bestProvider != \"\"\n}\n\nfunc sameURLServer(left, right *url.URL) bool {\n\tif left == nil || right == nil || !strings.EqualFold(left.Hostname(), right.Hostname()) {\n\t\treturn false\n\t}\n\treturn effectiveURLPort(left) == effectiveURLPort(right)\n}\n\nfunc effectiveURLPort(value *url.URL) string {\n\tif value == nil {\n\t\treturn \"\"\n\t}\n\tif port := value.Port(); port != \"\" {\n\t\treturn port\n\t}\n\tswitch strings.ToLower(value.Scheme) {\n\tcase \"https\":\n\t\treturn \"443\"\n\tcase \"http\":\n\t\treturn \"80\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// AutoAddRoutes 返回 provider 快速添加时需要创建的渠道 route。\nfunc (t *ProviderTemplate) AutoAddRoutes() []ProviderRoute {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tif len(t.Routes) > 0 {\n\t\tout := make([]ProviderRoute, len(t.Routes))\n\t\tcopy(out, t.Routes)\n\t\treturn out\n\t}\n\tif t.ChannelKind == \"\" || t.ServiceType == \"\" {\n\t\treturn nil\n\t}\n\treturn []ProviderRoute{{\n\t\tChannelKind: t.ChannelKind,\n\t\tServiceType: t.ServiceType,\n\t\tCandidates: append([]ProviderCandidate(nil), t.Candidates...),\n\t}}\n}\n\n// SupportsChannelKind 判断 provider 是否可从指定渠道页发起快速添加。\nfunc (t *ProviderTemplate) SupportsChannelKind(kind string) bool {\n\tfor _, route := range t.AutoAddRoutes() {\n\t\tif route.ChannelKind == kind {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// CandidatesForKey 按 API Key 前缀返回默认 route 的候选 baseURL 顺序。\n//\n// 规则(对应用户决策):\n// 1. key 前缀命中某 PlanTag → 该 plan 的候选优先(按 Priority 升序),其余候选追加在后作为回退\n// 2. 前缀不匹配任何规则(或模板无前缀规则)→ 返回全部候选(按 Priority 升序)\n//\n// 返回的候选已按探测顺序排列,调用方依次探测,命中即用。\nfunc (t *ProviderTemplate) CandidatesForKey(apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\treturn t.CandidatesForRouteKey(ProviderRoute{Candidates: t.Candidates}, apiKey)\n}\n\n// CandidatesForRouteKey 按 API Key 前缀返回指定 route 的候选 baseURL 顺序。\nfunc (t *ProviderTemplate) CandidatesForRouteKey(route ProviderRoute, apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tmatchedPlan := \"\"\n\tfor _, rule := range t.KeyPrefixRules {\n\t\tif rule.Prefix != \"\" && strings.HasPrefix(apiKey, rule.Prefix) {\n\t\t\tmatchedPlan = rule.PlanTag\n\t\t\tbreak\n\t\t}\n\t}\n\n\tsortByPriority := func(list []ProviderCandidate) []ProviderCandidate {\n\t\tsorted := make([]ProviderCandidate, len(list))\n\t\tcopy(sorted, list)\n\t\t// 稳定插入排序(候选数量极少,无需 sort 包)\n\t\tfor i := 1; i < len(sorted); i++ {\n\t\t\tfor j := i; j > 0 && sorted[j].Priority < sorted[j-1].Priority; j-- {\n\t\t\t\tsorted[j], sorted[j-1] = sorted[j-1], sorted[j]\n\t\t\t}\n\t\t}\n\t\treturn sorted\n\t}\n\n\tif matchedPlan == \"\" {\n\t\treturn sortByPriority(route.Candidates)\n\t}\n\n\tpreferred := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfallback := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfor _, cand := range route.Candidates {\n\t\tif cand.PlanTag == matchedPlan {\n\t\t\tpreferred = append(preferred, cand)\n\t\t} else {\n\t\t\tfallback = append(fallback, cand)\n\t\t}\n\t}\n\treturn append(sortByPriority(preferred), sortByPriority(fallback)...)\n}\n\nfunc mimoClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc mimoChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc volcenginePlanClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n\nfunc volcenginePlanChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n"}, "6dae122a2a3fa3a18aaf07992905ade59b215860": {"t": 1785759325.9656885, "n": 10815, "h": "5bf24cfceb676aab", "c": "package config\n\nimport \"strings\"\n\n// ProviderTemplate 描述一个官方 provider 的模板化添加配置。\n//\n// 设计目标:用户只需选择 provider + 输入 API Key,系统按 key 前缀选候选 baseURL,\n// 探测验证可用性后自动创建渠道,无需手填 baseURL / 选协议 / 配兼容开关。\n//\n// baseURL 按上游原生协议区分端点:Claude 请求优先走原生 Claude 入口;\n// Chat / Codex Responses 请求走 OpenAI Chat 兼容入口,由后端做协议转换。\n// ProviderTemplate 只描述来源和端点,不再承载 channel-presets 兼容开关;\n// autoManaged 渠道的模型与能力差异由后端智能调度/ModelResolver 处理。\ntype ProviderTemplate struct {\n\tProviderID string `json:\"providerId\"` // \"mimo\" / \"deepseek\" / ...\n\tDisplayName string `json:\"displayName\"` // \"小米 MiMo\"\n\tDescription string `json:\"description\"` // key 前缀说明等\n\tChannelKind string `json:\"channelKind\"` // 默认 route 的渠道类型,兼容旧前端\n\tServiceType string `json:\"serviceType\"` // 默认 route 的服务类型,兼容旧前端\n\tOriginType string `json:\"originType\"` // \"official_api\"\n\tOriginTier string `json:\"originTier\"` // \"first\"\n\tKeyPrefixRules []KeyPrefixRule `json:\"keyPrefixRules\"` // key 前缀 → plan 判别\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 默认 route 的候选 baseURL\n\tRoutes []ProviderRoute `json:\"routes,omitempty\"`\n}\n\n// ProviderRoute 描述同一 provider 在某个 CCX 渠道协议下使用的原生上游入口。\ntype ProviderRoute struct {\n\tChannelKind string `json:\"channelKind\"` // \"messages\" / \"chat\" / \"responses\"\n\tServiceType string `json:\"serviceType\"` // \"claude\" / \"openai\" / \"responses\"\n\tDescription string `json:\"description,omitempty\"` // route 说明,仅展示/诊断\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 该 route 的候选 baseURL\n}\n\n// KeyPrefixRule 按 API Key 前缀判别 plan 类型。\ntype KeyPrefixRule struct {\n\tPrefix string `json:\"prefix\"` // \"sk-\" / \"tp-\"\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\"\n}\n\n// ProviderCandidate 一个候选 baseURL 及其归属信息。\ntype ProviderCandidate struct {\n\tBaseURL string `json:\"baseUrl\"` // 完整 baseURL,如 https://api.xiaomimimo.com/anthropic\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\" / \"\"\n\tRegion string `json:\"region\"` // \"cn\" / \"sgp\" / \"ams\" / \"global\" / \"\"\n\tPriority int `json:\"priority\"` // 同 plan 内探测优先级(数字越小越先探测)\n}\n\n// builtinProviderTemplates 编译期内置的官方 provider 模板。\n//\n// URL 来源(2026-07 联网核实官方文档):\n// - MiMo: https://mimo.mi.com/docs(按量 sk- / Token Plan tp-,TP 分 cn/sgp/ams 三区域集群)\n// - DeepSeek: https://api-docs.deepseek.com(Anthropic 兼容 /anthropic)\n// - Kimi: https://api.moonshot.ai/anthropic(全球)/ https://api.moonshot.cn/anthropic(中国)\n// - GLM: https://open.bigmodel.cn/api/anthropic\n// - 火山方舟: https://ark.cn-beijing.volces.com/api/plan(Agent Plan)与 /api/coding(Coding Plan)\n//\n// Claude route 的 baseURL 使用 Anthropic 兼容入口且不带 /v1(claude provider 会自动补 /v1/messages)。\n// Chat/Responses/Gemini route 使用 OpenAI Chat 兼容入口 /v1(provider 自动拼 /chat/completions)。\nvar builtinProviderTemplates = []ProviderTemplate{\n\t{\n\t\tProviderID: \"mimo\",\n\t\tDisplayName: \"小米 MiMo\",\n\t\tDescription: \"sk- 按量付费 / tp- Token Plan 订阅(Claude 走 /anthropic,Chat/Codex/Gemini 走 /v1)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"sk-\", PlanTag: \"payg\"},\n\t\t\t{Prefix: \"tp-\", PlanTag: \"token_plan\"},\n\t\t},\n\t\tCandidates: mimoClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages 原生 Anthropic 兼容入口\",\n\t\t\t\tCandidates: mimoClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"deepseek\",\n\t\tDisplayName: \"DeepSeek\",\n\t\tDescription: \"DeepSeek 官方 API(Anthropic 兼容协议)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.deepseek.com/anthropic\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"volcengine\",\n\t\tDisplayName: \"火山方舟 Agent/Coding Plan\",\n\t\tDescription: \"ark- 套餐推理 Key(系统自动识别套餐;模型发现需为每个 Key 绑定火山云 Access Key)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"ark-\", PlanTag: \"personal_plan\"},\n\t\t},\n\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Agent/Coding Plan Claude Messages 入口\",\n\t\t\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Agent/Coding Plan OpenAI Chat Completions 入口\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"kimi\",\n\t\tDisplayName: \"Kimi (Moonshot)\",\n\t\tDescription: \"Moonshot Kimi 官方 API(Anthropic 兼容,自动判别全球/中国节点)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.moonshot.cn/anthropic\", PlanTag: \"\", Region: \"cn\", Priority: 0},\n\t\t\t{BaseURL: \"https://api.moonshot.ai/anthropic\", PlanTag: \"\", Region: \"global\", Priority: 1},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"glm\",\n\t\tDisplayName: \"智谱 GLM\",\n\t\tDescription: \"智谱 GLM 官方 API(Anthropic 兼容协议)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: []ProviderCandidate{\n\t\t\t{BaseURL: \"https://open.bigmodel.cn/api/anthropic\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t\t},\n\t},\n}\n\n// ListProviderTemplates 返回所有内置 provider 模板。\nfunc ListProviderTemplates() []ProviderTemplate {\n\tout := make([]ProviderTemplate, len(builtinProviderTemplates))\n\tcopy(out, builtinProviderTemplates)\n\treturn out\n}\n\n// GetProviderTemplate 按 providerId 查找模板,未找到返回 (nil, false)。\nfunc GetProviderTemplate(providerID string) (*ProviderTemplate, bool) {\n\tfor i := range builtinProviderTemplates {\n\t\tif builtinProviderTemplates[i].ProviderID == providerID {\n\t\t\treturn &builtinProviderTemplates[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// AutoAddRoutes 返回 provider 快速添加时需要创建的渠道 route。\nfunc (t *ProviderTemplate) AutoAddRoutes() []ProviderRoute {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tif len(t.Routes) > 0 {\n\t\tout := make([]ProviderRoute, len(t.Routes))\n\t\tcopy(out, t.Routes)\n\t\treturn out\n\t}\n\tif t.ChannelKind == \"\" || t.ServiceType == \"\" {\n\t\treturn nil\n\t}\n\treturn []ProviderRoute{{\n\t\tChannelKind: t.ChannelKind,\n\t\tServiceType: t.ServiceType,\n\t\tCandidates: append([]ProviderCandidate(nil), t.Candidates...),\n\t}}\n}\n\n// SupportsChannelKind 判断 provider 是否可从指定渠道页发起快速添加。\nfunc (t *ProviderTemplate) SupportsChannelKind(kind string) bool {\n\tfor _, route := range t.AutoAddRoutes() {\n\t\tif route.ChannelKind == kind {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// CandidatesForKey 按 API Key 前缀返回默认 route 的候选 baseURL 顺序。\n//\n// 规则(对应用户决策):\n// 1. key 前缀命中某 PlanTag → 该 plan 的候选优先(按 Priority 升序),其余候选追加在后作为回退\n// 2. 前缀不匹配任何规则(或模板无前缀规则)→ 返回全部候选(按 Priority 升序)\n//\n// 返回的候选已按探测顺序排列,调用方依次探测,命中即用。\nfunc (t *ProviderTemplate) CandidatesForKey(apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\treturn t.CandidatesForRouteKey(ProviderRoute{Candidates: t.Candidates}, apiKey)\n}\n\n// CandidatesForRouteKey 按 API Key 前缀返回指定 route 的候选 baseURL 顺序。\nfunc (t *ProviderTemplate) CandidatesForRouteKey(route ProviderRoute, apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tmatchedPlan := \"\"\n\tfor _, rule := range t.KeyPrefixRules {\n\t\tif rule.Prefix != \"\" && strings.HasPrefix(apiKey, rule.Prefix) {\n\t\t\tmatchedPlan = rule.PlanTag\n\t\t\tbreak\n\t\t}\n\t}\n\n\tsortByPriority := func(list []ProviderCandidate) []ProviderCandidate {\n\t\tsorted := make([]ProviderCandidate, len(list))\n\t\tcopy(sorted, list)\n\t\t// 稳定插入排序(候选数量极少,无需 sort 包)\n\t\tfor i := 1; i < len(sorted); i++ {\n\t\t\tfor j := i; j > 0 && sorted[j].Priority < sorted[j-1].Priority; j-- {\n\t\t\t\tsorted[j], sorted[j-1] = sorted[j-1], sorted[j]\n\t\t\t}\n\t\t}\n\t\treturn sorted\n\t}\n\n\tif matchedPlan == \"\" {\n\t\treturn sortByPriority(route.Candidates)\n\t}\n\n\tpreferred := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfallback := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfor _, cand := range route.Candidates {\n\t\tif cand.PlanTag == matchedPlan {\n\t\t\tpreferred = append(preferred, cand)\n\t\t} else {\n\t\t\tfallback = append(fallback, cand)\n\t\t}\n\t}\n\treturn append(sortByPriority(preferred), sortByPriority(fallback)...)\n}\n\nfunc mimoClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc mimoChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc volcenginePlanClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n\nfunc volcenginePlanChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n"}, "c3d5e4e76b9c81aa18fad112aebcf703968eb012": {"t": 1785759327.4410286, "n": 8956, "h": "5bd34bd0019905d4", "c": "package config\n\nimport \"strings\"\n\n// ProviderTemplate 描述一个官方 provider 的模板化添加配置。\n//\n// 设计目标:用户只需选择 provider + 输入 API Key,系统按 key 前缀选候选 baseURL,\n// 探测验证可用性后自动创建渠道,无需手填 baseURL / 选协议 / 配兼容开关。\n//\n// baseURL 按上游原生协议区分端点:Claude 请求优先走原生 Claude 入口;\n// Chat / Codex Responses 请求走 OpenAI Chat 兼容入口,由后端做协议转换。\n// ProviderTemplate 只描述来源和端点,不再承载 channel-presets 兼容开关;\n// autoManaged 渠道的模型与能力差异由后端智能调度/ModelResolver 处理。\ntype ProviderTemplate struct {\n\tProviderID string `json:\"providerId\"` // \"mimo\" / \"deepseek\" / ...\n\tDisplayName string `json:\"displayName\"` // \"小米 MiMo\"\n\tDescription string `json:\"description\"` // key 前缀说明等\n\tChannelKind string `json:\"channelKind\"` // 默认 route 的渠道类型,兼容旧前端\n\tServiceType string `json:\"serviceType\"` // 默认 route 的服务类型,兼容旧前端\n\tOriginType string `json:\"originType\"` // \"official_api\"\n\tOriginTier string `json:\"originTier\"` // \"first\"\n\tKeyPrefixRules []KeyPrefixRule `json:\"keyPrefixRules\"` // key 前缀 → plan 判别\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 默认 route 的候选 baseURL\n\tRoutes []ProviderRoute `json:\"routes,omitempty\"`\n}\n\n// ProviderRoute 描述同一 provider 在某个 CCX 渠道协议下使用的原生上游入口。\ntype ProviderRoute struct {\n\tChannelKind string `json:\"channelKind\"` // \"messages\" / \"chat\" / \"responses\"\n\tServiceType string `json:\"serviceType\"` // \"claude\" / \"openai\" / \"responses\"\n\tDescription string `json:\"description,omitempty\"` // route 说明,仅展示/诊断\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 该 route 的候选 baseURL\n}\n\n// KeyPrefixRule 按 API Key 前缀判别 plan 类型。\ntype KeyPrefixRule struct {\n\tPrefix string `json:\"prefix\"` // \"sk-\" / \"tp-\"\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\"\n}\n\n// ProviderCandidate 一个候选 baseURL 及其归属信息。\ntype ProviderCandidate struct {\n\tBaseURL string `json:\"baseUrl\"` // 完整 baseURL,如 https://api.xiaomimimo.com/anthropic\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\" / \"\"\n\tRegion string `json:\"region\"` // \"cn\" / \"sgp\" / \"ams\" / \"global\" / \"\"\n\tPriority int `json:\"priority\"` // 同 plan 内探测优先级(数字越小越先探测)\n}\n\n// builtinProviderTemplates 编译期内置的官方 provider 模板。\n//\n// URL 来源(2026-07 联网核实官方文档):\n// - MiMo: https://mimo.mi.com/docs(按量 sk- / Token Plan tp-,TP 分 cn/sgp/ams 三区域集群)\n// - DeepSeek: https://api-docs.deepseek.com(Anthropic 兼容 /anthropic)\n// - Kimi: https://api.moonshot.ai/anthropic(全球)/ https://api.moonshot.cn/anthropic(中国)\n// - GLM: https://open.bigmodel.cn/api/anthropic\n//\n// Claude route 的 baseURL 使用 Anthropic 兼容入口且不带 /v1(claude provider 会自动补 /v1/messages)。\n// Chat/Responses/Gemini route 使用 OpenAI Chat 兼容入口 /v1(provider 自动拼 /chat/completions)。\nvar builtinProviderTemplates = []ProviderTemplate{\n\t{\n\t\tProviderID: \"mimo\",\n\t\tDisplayName: \"小米 MiMo\",\n\t\tDescription: \"sk- 按量付费 / tp- Token Plan 订阅(Claude 走 /anthropic,Chat/Codex/Gemini 走 /v1)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"sk-\", PlanTag: \"payg\"},\n\t\t\t{Prefix: \"tp-\", PlanTag: \"token_plan\"},\n\t\t},\n\t\tCandidates: mimoClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages 原生 Anthropic 兼容入口\",\n\t\t\t\tCandidates: mimoClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"deepseek\",\n\t\tDisplayName: \"DeepSeek\",\n\t\tDescription: \"DeepSeek 官方 API(Anthropic 兼容协议)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.deepseek.com/anthropic\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"kimi\",\n\t\tDisplayName: \"Kimi (Moonshot)\",\n\t\tDescription: \"Moonshot Kimi 官方 API(Anthropic 兼容,自动判别全球/中国节点)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.moonshot.cn/anthropic\", PlanTag: \"\", Region: \"cn\", Priority: 0},\n\t\t\t{BaseURL: \"https://api.moonshot.ai/anthropic\", PlanTag: \"\", Region: \"global\", Priority: 1},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"glm\",\n\t\tDisplayName: \"智谱 GLM\",\n\t\tDescription: \"智谱 GLM 官方 API(Anthropic 兼容协议)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: []ProviderCandidate{\n\t\t\t{BaseURL: \"https://open.bigmodel.cn/api/anthropic\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t\t},\n\t},\n}\n\n// ListProviderTemplates 返回所有内置 provider 模板。\nfunc ListProviderTemplates() []ProviderTemplate {\n\tout := make([]ProviderTemplate, len(builtinProviderTemplates))\n\tcopy(out, builtinProviderTemplates)\n\treturn out\n}\n\n// GetProviderTemplate 按 providerId 查找模板,未找到返回 (nil, false)。\nfunc GetProviderTemplate(providerID string) (*ProviderTemplate, bool) {\n\tfor i := range builtinProviderTemplates {\n\t\tif builtinProviderTemplates[i].ProviderID == providerID {\n\t\t\treturn &builtinProviderTemplates[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// AutoAddRoutes 返回 provider 快速添加时需要创建的渠道 route。\nfunc (t *ProviderTemplate) AutoAddRoutes() []ProviderRoute {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tif len(t.Routes) > 0 {\n\t\tout := make([]ProviderRoute, len(t.Routes))\n\t\tcopy(out, t.Routes)\n\t\treturn out\n\t}\n\tif t.ChannelKind == \"\" || t.ServiceType == \"\" {\n\t\treturn nil\n\t}\n\treturn []ProviderRoute{{\n\t\tChannelKind: t.ChannelKind,\n\t\tServiceType: t.ServiceType,\n\t\tCandidates: append([]ProviderCandidate(nil), t.Candidates...),\n\t}}\n}\n\n// SupportsChannelKind 判断 provider 是否可从指定渠道页发起快速添加。\nfunc (t *ProviderTemplate) SupportsChannelKind(kind string) bool {\n\tfor _, route := range t.AutoAddRoutes() {\n\t\tif route.ChannelKind == kind {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// CandidatesForKey 按 API Key 前缀返回默认 route 的候选 baseURL 顺序。\n//\n// 规则(对应用户决策):\n// 1. key 前缀命中某 PlanTag → 该 plan 的候选优先(按 Priority 升序),其余候选追加在后作为回退\n// 2. 前缀不匹配任何规则(或模板无前缀规则)→ 返回全部候选(按 Priority 升序)\n//\n// 返回的候选已按探测顺序排列,调用方依次探测,命中即用。\nfunc (t *ProviderTemplate) CandidatesForKey(apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\treturn t.CandidatesForRouteKey(ProviderRoute{Candidates: t.Candidates}, apiKey)\n}\n\n// CandidatesForRouteKey 按 API Key 前缀返回指定 route 的候选 baseURL 顺序。\nfunc (t *ProviderTemplate) CandidatesForRouteKey(route ProviderRoute, apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tmatchedPlan := \"\"\n\tfor _, rule := range t.KeyPrefixRules {\n\t\tif rule.Prefix != \"\" && strings.HasPrefix(apiKey, rule.Prefix) {\n\t\t\tmatchedPlan = rule.PlanTag\n\t\t\tbreak\n\t\t}\n\t}\n\n\tsortByPriority := func(list []ProviderCandidate) []ProviderCandidate {\n\t\tsorted := make([]ProviderCandidate, len(list))\n\t\tcopy(sorted, list)\n\t\t// 稳定插入排序(候选数量极少,无需 sort 包)\n\t\tfor i := 1; i < len(sorted); i++ {\n\t\t\tfor j := i; j > 0 && sorted[j].Priority < sorted[j-1].Priority; j-- {\n\t\t\t\tsorted[j], sorted[j-1] = sorted[j-1], sorted[j]\n\t\t\t}\n\t\t}\n\t\treturn sorted\n\t}\n\n\tif matchedPlan == \"\" {\n\t\treturn sortByPriority(route.Candidates)\n\t}\n\n\tpreferred := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfallback := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfor _, cand := range route.Candidates {\n\t\tif cand.PlanTag == matchedPlan {\n\t\t\tpreferred = append(preferred, cand)\n\t\t} else {\n\t\t\tfallback = append(fallback, cand)\n\t\t}\n\t}\n\treturn append(sortByPriority(preferred), sortByPriority(fallback)...)\n}\n\nfunc mimoClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc mimoChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n"}, "5cfccf226a320c70c7da2c8c594bc5980bba37a0": {"t": 1785759328.7581475, "n": 8781, "h": "08540ec77c4567ca", "c": "package config\n\nimport \"strings\"\n\n// ProviderTemplate 描述一个官方 provider 的模板化添加配置。\n//\n// 设计目标:用户只需选择 provider + 输入 API Key,系统按 key 前缀选候选 baseURL,\n// 探测验证可用性后自动创建渠道,无需手填 baseURL / 选协议 / 配兼容开关。\n//\n// baseURL 按上游原生协议区分端点:Claude 请求优先走原生 Claude 入口;\n// Chat / Codex Responses 请求走 OpenAI Chat 兼容入口,由后端做协议转换。\n// ProviderTemplate 只描述来源和端点,不再承载 channel-presets 兼容开关;\n// autoManaged 渠道的模型与能力差异由后端智能调度/ModelResolver 处理。\ntype ProviderTemplate struct {\n\tProviderID string `json:\"providerId\"` // \"mimo\" / \"deepseek\" / ...\n\tDisplayName string `json:\"displayName\"` // \"小米 MiMo\"\n\tDescription string `json:\"description\"` // key 前缀说明等\n\tChannelKind string `json:\"channelKind\"` // 默认 route 的渠道类型,兼容旧前端\n\tServiceType string `json:\"serviceType\"` // 默认 route 的服务类型,兼容旧前端\n\tOriginType string `json:\"originType\"` // \"official_api\"\n\tOriginTier string `json:\"originTier\"` // \"first\"\n\tKeyPrefixRules []KeyPrefixRule `json:\"keyPrefixRules\"` // key 前缀 → plan 判别\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 默认 route 的候选 baseURL\n\tRoutes []ProviderRoute `json:\"routes,omitempty\"`\n}\n\n// ProviderRoute 描述同一 provider 在某个 CCX 渠道协议下使用的原生上游入口。\ntype ProviderRoute struct {\n\tChannelKind string `json:\"channelKind\"` // \"messages\" / \"chat\" / \"responses\"\n\tServiceType string `json:\"serviceType\"` // \"claude\" / \"openai\" / \"responses\"\n\tDescription string `json:\"description,omitempty\"` // route 说明,仅展示/诊断\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 该 route 的候选 baseURL\n}\n\n// KeyPrefixRule 按 API Key 前缀判别 plan 类型。\ntype KeyPrefixRule struct {\n\tPrefix string `json:\"prefix\"` // \"sk-\" / \"tp-\"\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\"\n}\n\n// ProviderCandidate 一个候选 baseURL 及其归属信息。\ntype ProviderCandidate struct {\n\tBaseURL string `json:\"baseUrl\"` // 完整 baseURL,如 https://api.xiaomimimo.com/anthropic\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\" / \"\"\n\tRegion string `json:\"region\"` // \"cn\" / \"sgp\" / \"ams\" / \"global\" / \"\"\n\tPriority int `json:\"priority\"` // 同 plan 内探测优先级(数字越小越先探测)\n}\n\n// builtinProviderTemplates 编译期内置的官方 provider 模板。\n//\n// URL 来源(2026-07 联网核实官方文档):\n// - MiMo: https://mimo.mi.com/docs(按量 sk- / Token Plan tp-,TP 分 cn/sgp/ams 三区域集群)\n// - DeepSeek: https://api-docs.deepseek.com(Anthropic 兼容 /anthropic)\n// - Kimi: https://api.moonshot.ai/anthropic(全球)/ https://api.moonshot.cn/anthropic(中国)\n// - GLM: https://open.bigmodel.cn/api/anthropic\n//\n// Claude route 的 baseURL 使用 Anthropic 兼容入口且不带 /v1(claude provider 会自动补 /v1/messages)。\n// Chat/Responses route 使用 OpenAI Chat 兼容入口 /v1(provider 自动拼 /chat/completions)。\nvar builtinProviderTemplates = []ProviderTemplate{\n\t{\n\t\tProviderID: \"mimo\",\n\t\tDisplayName: \"小米 MiMo\",\n\t\tDescription: \"sk- 按量付费 / tp- Token Plan 订阅(Claude 走 /anthropic,Chat/Codex 走 /v1)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"sk-\", PlanTag: \"payg\"},\n\t\t\t{Prefix: \"tp-\", PlanTag: \"token_plan\"},\n\t\t},\n\t\tCandidates: mimoClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages 原生 Anthropic 兼容入口\",\n\t\t\t\tCandidates: mimoClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"deepseek\",\n\t\tDisplayName: \"DeepSeek\",\n\t\tDescription: \"DeepSeek 官方 API(Anthropic 兼容协议)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.deepseek.com/anthropic\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"kimi\",\n\t\tDisplayName: \"Kimi (Moonshot)\",\n\t\tDescription: \"Moonshot Kimi 官方 API(Anthropic 兼容,自动判别全球/中国节点)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.moonshot.cn/anthropic\", PlanTag: \"\", Region: \"cn\", Priority: 0},\n\t\t\t{BaseURL: \"https://api.moonshot.ai/anthropic\", PlanTag: \"\", Region: \"global\", Priority: 1},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"glm\",\n\t\tDisplayName: \"智谱 GLM\",\n\t\tDescription: \"智谱 GLM 官方 API(Anthropic 兼容协议)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: []ProviderCandidate{\n\t\t\t{BaseURL: \"https://open.bigmodel.cn/api/anthropic\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t\t},\n\t},\n}\n\n// ListProviderTemplates 返回所有内置 provider 模板。\nfunc ListProviderTemplates() []ProviderTemplate {\n\tout := make([]ProviderTemplate, len(builtinProviderTemplates))\n\tcopy(out, builtinProviderTemplates)\n\treturn out\n}\n\n// GetProviderTemplate 按 providerId 查找模板,未找到返回 (nil, false)。\nfunc GetProviderTemplate(providerID string) (*ProviderTemplate, bool) {\n\tfor i := range builtinProviderTemplates {\n\t\tif builtinProviderTemplates[i].ProviderID == providerID {\n\t\t\treturn &builtinProviderTemplates[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// AutoAddRoutes 返回 provider 快速添加时需要创建的渠道 route。\nfunc (t *ProviderTemplate) AutoAddRoutes() []ProviderRoute {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tif len(t.Routes) > 0 {\n\t\tout := make([]ProviderRoute, len(t.Routes))\n\t\tcopy(out, t.Routes)\n\t\treturn out\n\t}\n\tif t.ChannelKind == \"\" || t.ServiceType == \"\" {\n\t\treturn nil\n\t}\n\treturn []ProviderRoute{{\n\t\tChannelKind: t.ChannelKind,\n\t\tServiceType: t.ServiceType,\n\t\tCandidates: append([]ProviderCandidate(nil), t.Candidates...),\n\t}}\n}\n\n// SupportsChannelKind 判断 provider 是否可从指定渠道页发起快速添加。\nfunc (t *ProviderTemplate) SupportsChannelKind(kind string) bool {\n\tfor _, route := range t.AutoAddRoutes() {\n\t\tif route.ChannelKind == kind {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// CandidatesForKey 按 API Key 前缀返回默认 route 的候选 baseURL 顺序。\n//\n// 规则(对应用户决策):\n// 1. key 前缀命中某 PlanTag → 该 plan 的候选优先(按 Priority 升序),其余候选追加在后作为回退\n// 2. 前缀不匹配任何规则(或模板无前缀规则)→ 返回全部候选(按 Priority 升序)\n//\n// 返回的候选已按探测顺序排列,调用方依次探测,命中即用。\nfunc (t *ProviderTemplate) CandidatesForKey(apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\treturn t.CandidatesForRouteKey(ProviderRoute{Candidates: t.Candidates}, apiKey)\n}\n\n// CandidatesForRouteKey 按 API Key 前缀返回指定 route 的候选 baseURL 顺序。\nfunc (t *ProviderTemplate) CandidatesForRouteKey(route ProviderRoute, apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tmatchedPlan := \"\"\n\tfor _, rule := range t.KeyPrefixRules {\n\t\tif rule.Prefix != \"\" && strings.HasPrefix(apiKey, rule.Prefix) {\n\t\t\tmatchedPlan = rule.PlanTag\n\t\t\tbreak\n\t\t}\n\t}\n\n\tsortByPriority := func(list []ProviderCandidate) []ProviderCandidate {\n\t\tsorted := make([]ProviderCandidate, len(list))\n\t\tcopy(sorted, list)\n\t\t// 稳定插入排序(候选数量极少,无需 sort 包)\n\t\tfor i := 1; i < len(sorted); i++ {\n\t\t\tfor j := i; j > 0 && sorted[j].Priority < sorted[j-1].Priority; j-- {\n\t\t\t\tsorted[j], sorted[j-1] = sorted[j-1], sorted[j]\n\t\t\t}\n\t\t}\n\t\treturn sorted\n\t}\n\n\tif matchedPlan == \"\" {\n\t\treturn sortByPriority(route.Candidates)\n\t}\n\n\tpreferred := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfallback := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfor _, cand := range route.Candidates {\n\t\tif cand.PlanTag == matchedPlan {\n\t\t\tpreferred = append(preferred, cand)\n\t\t} else {\n\t\t\tfallback = append(fallback, cand)\n\t\t}\n\t}\n\treturn append(sortByPriority(preferred), sortByPriority(fallback)...)\n}\n\nfunc mimoClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc mimoChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n"}, "ffa88710c25307f08590908fc0c707ec80d6ab21": {"t": 1785759330.7566211, "n": 6027, "h": "00e0f6e52de330f1", "c": "package config\n\nimport \"strings\"\n\n// ProviderTemplate 描述一个官方 provider 的模板化添加配置。\n//\n// 设计目标:用户只需选择 provider + 输入 API Key,系统按 key 前缀选候选 baseURL,\n// 探测验证可用性后自动创建渠道,无需手填 baseURL / 选协议 / 配兼容开关。\n//\n// baseURL 按上游原生协议区分端点,调用时走该 baseURL 的原生协议路径,零协议转换。\n// ProviderTemplate 只描述来源和端点,不再承载 channel-presets 兼容开关;\n// autoManaged 渠道的模型与能力差异由后端智能调度/ModelResolver 处理。\n// 首批 provider 均使用 Anthropic 兼容入口(ServiceType=claude / ChannelKind=messages)。\ntype ProviderTemplate struct {\n\tProviderID string `json:\"providerId\"` // \"mimo\" / \"deepseek\" / ...\n\tDisplayName string `json:\"displayName\"` // \"小米 MiMo\"\n\tDescription string `json:\"description\"` // key 前缀说明等\n\tChannelKind string `json:\"channelKind\"` // \"messages\" / \"chat\"\n\tServiceType string `json:\"serviceType\"` // \"claude\" / \"openai\"\n\tOriginType string `json:\"originType\"` // \"official_api\"\n\tOriginTier string `json:\"originTier\"` // \"first\"\n\tKeyPrefixRules []KeyPrefixRule `json:\"keyPrefixRules\"` // key 前缀 → plan 判别\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 候选 baseURL(含 plan 标签)\n}\n\n// KeyPrefixRule 按 API Key 前缀判别 plan 类型。\ntype KeyPrefixRule struct {\n\tPrefix string `json:\"prefix\"` // \"sk-\" / \"tp-\"\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\"\n}\n\n// ProviderCandidate 一个候选 baseURL 及其归属信息。\ntype ProviderCandidate struct {\n\tBaseURL string `json:\"baseUrl\"` // 完整 baseURL,如 https://api.xiaomimimo.com/anthropic\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\" / \"\"\n\tRegion string `json:\"region\"` // \"cn\" / \"sgp\" / \"ams\" / \"global\" / \"\"\n\tPriority int `json:\"priority\"` // 同 plan 内探测优先级(数字越小越先探测)\n}\n\n// builtinProviderTemplates 编译期内置的官方 provider 模板。\n//\n// URL 来源(2026-07 联网核实官方文档):\n// - MiMo: https://mimo.mi.com/docs(按量 sk- / Token Plan tp-,TP 分 cn/sgp/ams 三区域集群)\n// - DeepSeek: https://api-docs.deepseek.com(Anthropic 兼容 /anthropic)\n// - Kimi: https://api.moonshot.ai/anthropic(全球)/ https://api.moonshot.cn/anthropic(中国)\n// - GLM: https://open.bigmodel.cn/api/anthropic\n//\n// baseURL 一律填 Anthropic 兼容入口且不带 /v1(claude provider 会自动补 /v1/messages)。\nvar builtinProviderTemplates = []ProviderTemplate{\n\t{\n\t\tProviderID: \"mimo\",\n\t\tDisplayName: \"小米 MiMo\",\n\t\tDescription: \"sk- 按量付费 / tp- Token Plan 订阅(自动判别区域集群)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"sk-\", PlanTag: \"payg\"},\n\t\t\t{Prefix: \"tp-\", PlanTag: \"token_plan\"},\n\t\t},\n\t\tCandidates: []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.xiaomimimo.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"deepseek\",\n\t\tDisplayName: \"DeepSeek\",\n\t\tDescription: \"DeepSeek 官方 API(Anthropic 兼容协议)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.deepseek.com/anthropic\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"kimi\",\n\t\tDisplayName: \"Kimi (Moonshot)\",\n\t\tDescription: \"Moonshot Kimi 官方 API(Anthropic 兼容,自动判别全球/中国节点)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.moonshot.cn/anthropic\", PlanTag: \"\", Region: \"cn\", Priority: 0},\n\t\t\t{BaseURL: \"https://api.moonshot.ai/anthropic\", PlanTag: \"\", Region: \"global\", Priority: 1},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"glm\",\n\t\tDisplayName: \"智谱 GLM\",\n\t\tDescription: \"智谱 GLM 官方 API(Anthropic 兼容协议)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: []ProviderCandidate{\n\t\t\t{BaseURL: \"https://open.bigmodel.cn/api/anthropic\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t\t},\n\t},\n}\n\n// ListProviderTemplates 返回所有内置 provider 模板。\nfunc ListProviderTemplates() []ProviderTemplate {\n\tout := make([]ProviderTemplate, len(builtinProviderTemplates))\n\tcopy(out, builtinProviderTemplates)\n\treturn out\n}\n\n// GetProviderTemplate 按 providerId 查找模板,未找到返回 (nil, false)。\nfunc GetProviderTemplate(providerID string) (*ProviderTemplate, bool) {\n\tfor i := range builtinProviderTemplates {\n\t\tif builtinProviderTemplates[i].ProviderID == providerID {\n\t\t\treturn &builtinProviderTemplates[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// CandidatesForKey 按 API Key 前缀返回该 key 应优先探测的候选 baseURL 顺序。\n//\n// 规则(对应用户决策):\n// 1. key 前缀命中某 PlanTag → 该 plan 的候选优先(按 Priority 升序),其余候选追加在后作为回退\n// 2. 前缀不匹配任何规则(或模板无前缀规则)→ 返回全部候选(按 Priority 升序)\n//\n// 返回的候选已按探测顺序排列,调用方依次探测,命中即用。\nfunc (t *ProviderTemplate) CandidatesForKey(apiKey string) []ProviderCandidate {\n\tmatchedPlan := \"\"\n\tfor _, rule := range t.KeyPrefixRules {\n\t\tif rule.Prefix != \"\" && strings.HasPrefix(apiKey, rule.Prefix) {\n\t\t\tmatchedPlan = rule.PlanTag\n\t\t\tbreak\n\t\t}\n\t}\n\n\tsortByPriority := func(list []ProviderCandidate) []ProviderCandidate {\n\t\tsorted := make([]ProviderCandidate, len(list))\n\t\tcopy(sorted, list)\n\t\t// 稳定插入排序(候选数量极少,无需 sort 包)\n\t\tfor i := 1; i < len(sorted); i++ {\n\t\t\tfor j := i; j > 0 && sorted[j].Priority < sorted[j-1].Priority; j-- {\n\t\t\t\tsorted[j], sorted[j-1] = sorted[j-1], sorted[j]\n\t\t\t}\n\t\t}\n\t\treturn sorted\n\t}\n\n\tif matchedPlan == \"\" {\n\t\treturn sortByPriority(t.Candidates)\n\t}\n\n\tpreferred := make([]ProviderCandidate, 0, len(t.Candidates))\n\tfallback := make([]ProviderCandidate, 0, len(t.Candidates))\n\tfor _, cand := range t.Candidates {\n\t\tif cand.PlanTag == matchedPlan {\n\t\t\tpreferred = append(preferred, cand)\n\t\t} else {\n\t\t\tfallback = append(fallback, cand)\n\t\t}\n\t}\n\treturn append(sortByPriority(preferred), sortByPriority(fallback)...)\n}\n"}, "9f641ade6dfc313ec869e0911d11b5b499db8bcc": {"t": 1785759332.3855457, "n": 6574, "h": "5f56c03a5e368ab2", "c": "package config\n\nimport \"strings\"\n\n// ProviderTemplate 描述一个官方 provider 的模板化添加配置。\n//\n// 设计目标:用户只需选择 provider + 输入 API Key,系统按 key 前缀选候选 baseURL,\n// 探测验证可用性后自动创建渠道,无需手填 baseURL / 选协议 / 配兼容开关。\n//\n// baseURL 按上游原生协议区分端点,调用时走该 baseURL 的原生协议路径,零协议转换。\n// 首批 provider 均使用 Anthropic 兼容入口(ServiceType=claude / ChannelKind=messages)。\ntype ProviderTemplate struct {\n\tProviderID string `json:\"providerId\"` // \"mimo\" / \"deepseek\" / ...\n\tDisplayName string `json:\"displayName\"` // \"小米 MiMo\"\n\tDescription string `json:\"description\"` // key 前缀说明等\n\tChannelKind string `json:\"channelKind\"` // \"messages\" / \"chat\"\n\tServiceType string `json:\"serviceType\"` // \"claude\" / \"openai\"\n\tOriginType string `json:\"originType\"` // \"official_api\"\n\tOriginTier string `json:\"originTier\"` // \"first\"\n\tKeyPrefixRules []KeyPrefixRule `json:\"keyPrefixRules\"` // key 前缀 → plan 判别\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 候选 baseURL(含 plan 标签)\n\tPresetRef string `json:\"presetRef\"` // channel-presets.json 的 provider key(用于后端 apply 预设)\n\tPresetCollection string `json:\"presetCollection\"` // 预设集合名(如 \"claudeMessages\")\n}\n\n// KeyPrefixRule 按 API Key 前缀判别 plan 类型。\ntype KeyPrefixRule struct {\n\tPrefix string `json:\"prefix\"` // \"sk-\" / \"tp-\"\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\"\n}\n\n// ProviderCandidate 一个候选 baseURL 及其归属信息。\ntype ProviderCandidate struct {\n\tBaseURL string `json:\"baseUrl\"` // 完整 baseURL,如 https://api.xiaomimimo.com/anthropic\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\" / \"\"\n\tRegion string `json:\"region\"` // \"cn\" / \"sgp\" / \"ams\" / \"global\" / \"\"\n\tPriority int `json:\"priority\"` // 同 plan 内探测优先级(数字越小越先探测)\n}\n\n// builtinProviderTemplates 编译期内置的官方 provider 模板。\n//\n// URL 来源(2026-07 联网核实官方文档):\n// - MiMo: https://mimo.mi.com/docs(按量 sk- / Token Plan tp-,TP 分 cn/sgp/ams 三区域集群)\n// - DeepSeek: https://api-docs.deepseek.com(Anthropic 兼容 /anthropic)\n// - Kimi: https://api.moonshot.ai/anthropic(全球)/ https://api.moonshot.cn/anthropic(中国)\n// - GLM: https://open.bigmodel.cn/api/anthropic\n//\n// baseURL 一律填 Anthropic 兼容入口且不带 /v1(claude provider 会自动补 /v1/messages)。\nvar builtinProviderTemplates = []ProviderTemplate{\n\t{\n\t\tProviderID: \"mimo\",\n\t\tDisplayName: \"小米 MiMo\",\n\t\tDescription: \"sk- 按量付费 / tp- Token Plan 订阅(自动判别区域集群)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tPresetRef: \"mimo\",\n\t\tPresetCollection: \"claudeMessages\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"sk-\", PlanTag: \"payg\"},\n\t\t\t{Prefix: \"tp-\", PlanTag: \"token_plan\"},\n\t\t},\n\t\tCandidates: []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.xiaomimimo.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"deepseek\",\n\t\tDisplayName: \"DeepSeek\",\n\t\tDescription: \"DeepSeek 官方 API(Anthropic 兼容协议)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tPresetRef: \"deepseek\",\n\t\tPresetCollection: \"claudeMessages\",\n\t\tCandidates: []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.deepseek.com/anthropic\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"kimi\",\n\t\tDisplayName: \"Kimi (Moonshot)\",\n\t\tDescription: \"Moonshot Kimi 官方 API(Anthropic 兼容,自动判别全球/中国节点)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tPresetRef: \"kimi\",\n\t\tPresetCollection: \"claudeMessages\",\n\t\tCandidates: []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.moonshot.cn/anthropic\", PlanTag: \"\", Region: \"cn\", Priority: 0},\n\t\t\t{BaseURL: \"https://api.moonshot.ai/anthropic\", PlanTag: \"\", Region: \"global\", Priority: 1},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"glm\",\n\t\tDisplayName: \"智谱 GLM\",\n\t\tDescription: \"智谱 GLM 官方 API(Anthropic 兼容协议)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tPresetRef: \"glm\",\n\t\tPresetCollection: \"claudeMessages\",\n\t\tCandidates: []ProviderCandidate{\n\t\t\t{BaseURL: \"https://open.bigmodel.cn/api/anthropic\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t\t},\n\t},\n}\n\n// ListProviderTemplates 返回所有内置 provider 模板。\nfunc ListProviderTemplates() []ProviderTemplate {\n\tout := make([]ProviderTemplate, len(builtinProviderTemplates))\n\tcopy(out, builtinProviderTemplates)\n\treturn out\n}\n\n// GetProviderTemplate 按 providerId 查找模板,未找到返回 (nil, false)。\nfunc GetProviderTemplate(providerID string) (*ProviderTemplate, bool) {\n\tfor i := range builtinProviderTemplates {\n\t\tif builtinProviderTemplates[i].ProviderID == providerID {\n\t\t\treturn &builtinProviderTemplates[i], true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// CandidatesForKey 按 API Key 前缀返回该 key 应优先探测的候选 baseURL 顺序。\n//\n// 规则(对应用户决策):\n// 1. key 前缀命中某 PlanTag → 该 plan 的候选优先(按 Priority 升序),其余候选追加在后作为回退\n// 2. 前缀不匹配任何规则(或模板无前缀规则)→ 返回全部候选(按 Priority 升序)\n//\n// 返回的候选已按探测顺序排列,调用方依次探测,命中即用。\nfunc (t *ProviderTemplate) CandidatesForKey(apiKey string) []ProviderCandidate {\n\tmatchedPlan := \"\"\n\tfor _, rule := range t.KeyPrefixRules {\n\t\tif rule.Prefix != \"\" && strings.HasPrefix(apiKey, rule.Prefix) {\n\t\t\tmatchedPlan = rule.PlanTag\n\t\t\tbreak\n\t\t}\n\t}\n\n\tsortByPriority := func(list []ProviderCandidate) []ProviderCandidate {\n\t\tsorted := make([]ProviderCandidate, len(list))\n\t\tcopy(sorted, list)\n\t\t// 稳定插入排序(候选数量极少,无需 sort 包)\n\t\tfor i := 1; i < len(sorted); i++ {\n\t\t\tfor j := i; j > 0 && sorted[j].Priority < sorted[j-1].Priority; j-- {\n\t\t\t\tsorted[j], sorted[j-1] = sorted[j-1], sorted[j]\n\t\t\t}\n\t\t}\n\t\treturn sorted\n\t}\n\n\tif matchedPlan == \"\" {\n\t\treturn sortByPriority(t.Candidates)\n\t}\n\n\tpreferred := make([]ProviderCandidate, 0, len(t.Candidates))\n\tfallback := make([]ProviderCandidate, 0, len(t.Candidates))\n\tfor _, cand := range t.Candidates {\n\t\tif cand.PlanTag == matchedPlan {\n\t\t\tpreferred = append(preferred, cand)\n\t\t} else {\n\t\t\tfallback = append(fallback, cand)\n\t\t}\n\t}\n\treturn append(sortByPriority(preferred), sortByPriority(fallback)...)\n}\n"}, "784041ebc2a680321b17c44db2f89e95b9470dbe": {"t": 1785809840.4792397, "n": 25135, "h": "4b8622458f235472", "c": "package config\n\nimport (\n\t\"math\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n)\n\n// ProviderTemplate 描述一个已知 provider 的模板化添加配置。\n//\n// 设计目标:用户只需选择 provider + 输入 API Key,系统按 key 前缀选候选 baseURL,\n// 探测验证可用性后自动创建渠道,无需手填 baseURL / 选协议 / 配兼容开关。\n//\n// baseURL 按上游原生协议区分端点:Claude 请求优先走原生 Claude 入口;\n// Chat / Codex Responses 请求走 OpenAI Chat 兼容入口,由后端做协议转换。\n// ProviderTemplate 只描述来源和端点,不再承载 channel-presets 兼容开关;\n// autoManaged 渠道的模型与能力差异由后端智能调度/ModelResolver 处理。\ntype ProviderTemplate struct {\n\tProviderID string `json:\"providerId\"` // \"mimo\" / \"deepseek\" / ...\n\tAliases []string `json:\"aliases,omitempty\"`\n\tDisplayName string `json:\"displayName\"` // \"小米 MiMo\"\n\tDescription string `json:\"description\"` // key 前缀说明等\n\tChannelKind string `json:\"channelKind\"` // 默认 route 的渠道类型,兼容旧前端\n\tServiceType string `json:\"serviceType\"` // 默认 route 的服务类型,兼容旧前端\n\tOriginType string `json:\"originType\"` // official_api / official_token_plan / relay\n\tOriginTier string `json:\"originTier\"` // first / second\n\tKeyPrefixRules []KeyPrefixRule `json:\"keyPrefixRules\"` // key 前缀 → plan 判别\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 默认 route 的候选 baseURL\n\tRoutes []ProviderRoute `json:\"routes,omitempty\"`\n\t// ModelCostMultipliers 描述同一 provider 套餐内每次模型调用的相对消耗。\n\t// key 支持模型精确 ID 或通配符;值越低越省,只在质量与实测表现相同后参与选优。\n\tModelCostMultipliers map[string]float64 `json:\"modelCostMultipliers,omitempty\"`\n\t// ModelQualityPriorities 描述 provider 已确认的同质量档模型能力顺序,值越高越强。\n\t// 该映射允许不完整;解析器仅在同档候选全部命中时比较,避免未知模型被无依据降权。\n\tModelQualityPriorities map[string]int `json:\"modelQualityPriorities,omitempty\"`\n}\n\n// ProviderRoute 描述同一 provider 在某个 CCX 渠道协议下使用的原生上游入口。\ntype ProviderRoute struct {\n\tChannelKind string `json:\"channelKind\"` // \"messages\" / \"chat\" / \"responses\"\n\tServiceType string `json:\"serviceType\"` // \"claude\" / \"openai\" / \"responses\"\n\tDescription string `json:\"description,omitempty\"` // route 说明,仅展示/诊断\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 该 route 的候选 baseURL\n\t// SupportedModels 限定该 route 端点实际接受的模型,语义与 UpstreamConfig.SupportedModels 一致\n\t// (支持精确匹配、prefix* / *suffix / *contains*,以及 ! 前缀排除)。\n\t//\n\t// 仅在上游同一 baseURL 下不同协议端点的模型支持范围不一致时才需要填写:\n\t// 例如 DeepSeek 的 /v1/responses 只接受 deepseek-v4-flash,而 /v1/chat/completions\n\t// 同时接受 flash 与 pro。留空表示该 route 不额外限制模型。\n\tSupportedModels []string `json:\"supportedModels,omitempty\"`\n}\n\n// KeyPrefixRule 按 API Key 前缀判别 plan 类型。\ntype KeyPrefixRule struct {\n\tPrefix string `json:\"prefix\"` // \"sk-\" / \"tp-\"\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\"\n}\n\n// ProviderCandidate 一个候选 baseURL 及其归属信息。\ntype ProviderCandidate struct {\n\tBaseURL string `json:\"baseUrl\"` // 完整 baseURL,如 https://api.xiaomimimo.com/anthropic\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\" / \"\"\n\tRegion string `json:\"region\"` // \"cn\" / \"sgp\" / \"ams\" / \"global\" / \"\"\n\tPriority int `json:\"priority\"` // 同 plan 内探测优先级(数字越小越先探测)\n}\n\n// builtinProviderTemplates 编译期内置的已知 provider 模板。\n//\n// URL 来源(2026-07 联网核实官方文档):\n// - MiMo: https://mimo.mi.com/docs(按量 sk- / Token Plan tp-,TP 分 cn/sgp/ams 三区域集群)\n// - DeepSeek: https://api-docs.deepseek.com(Anthropic 兼容 /anthropic)\n// - Kimi: https://api.moonshot.ai/anthropic(全球)/ https://api.moonshot.cn/anthropic(中国)\n// - GLM: https://open.bigmodel.cn/api/anthropic(Claude)与 /api/paas/v4(OpenAI)\n// - 火山方舟: https://ark.cn-beijing.volces.com/api/plan(Agent Plan)与 /api/coding(Coding Plan)\n//\n// Claude route 的 baseURL 使用 Anthropic 兼容入口且不带 /v1(claude provider 会自动补 /v1/messages)。\n// Chat/Responses route 使用 OpenAI Chat 兼容入口,由 provider 自动补协议端点。\nvar builtinProviderTemplates = []ProviderTemplate{\n\t{\n\t\tProviderID: \"mimo\",\n\t\tDisplayName: \"小米 MiMo\",\n\t\tDescription: \"sk- 按量付费 / tp- Token Plan 订阅(Claude 走 /anthropic,Chat/Codex/Gemini 走 /v1)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"sk-\", PlanTag: \"payg\"},\n\t\t\t{Prefix: \"tp-\", PlanTag: \"token_plan\"},\n\t\t},\n\t\tCandidates: mimoClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages 原生 Anthropic 兼容入口\",\n\t\t\t\tCandidates: mimoClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"deepseek\",\n\t\tDisplayName: \"DeepSeek\",\n\t\tDescription: \"DeepSeek 官方 API(Claude Messages、OpenAI Chat 与 Responses 三协议原生兼容;Responses 仅支持 deepseek-v4-flash)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: deepseekClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages Anthropic 兼容入口\",\n\t\t\t\tCandidates: deepseekClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: deepseekOpenAICandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"responses\",\n\t\t\t\tDescription: \"OpenAI Responses 原生入口(仅 deepseek-v4-flash;v4-pro 预计 2026-08 初支持)\",\n\t\t\t\tCandidates: deepseekResponsesCandidates(),\n\t\t\t\t// 上游对不支持的模型返回 400 而非忽略,因此在渠道层就限定为正向白名单:\n\t\t\t\t// 未知新模型不会被误放进该端点,v4-pro 上线后在此追加即可。\n\t\t\t\tSupportedModels: []string{\"deepseek-v4-flash\"},\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"volcengine\",\n\t\tAliases: []string{\"volc-ark\"},\n\t\tDisplayName: \"火山方舟 Agent/Coding Plan\",\n\t\tDescription: \"ark- 套餐推理 Key。Agent Plan 支持 AFP 感知路由(详见 volcengine_afp_pricing.go);Coding Plan 计费规则未核验,仅使用通用定价。模型发现需为每个 Key 绑定火山云 Access Key。\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"ark-\", PlanTag: \"personal_plan\"},\n\t\t},\n\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Agent/Coding Plan Claude Messages 入口\",\n\t\t\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Agent/Coding Plan OpenAI Chat Completions 入口\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"kimi\",\n\t\tAliases: []string{\"kimi-code\"},\n\t\tDisplayName: \"Kimi (Moonshot)\",\n\t\tDescription: \"Moonshot Kimi 官方 API(按量与 Coding Plan,自动判别可用入口)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: kimiClaudeCandidates(),\n\t\tRoutes: standardProviderRoutes(\"claude\", kimiClaudeCandidates(), kimiOpenAICandidates()),\n\t},\n\t{\n\t\tProviderID: \"glm\",\n\t\tDisplayName: \"智谱 GLM\",\n\t\tDescription: \"智谱 GLM 官方 API(自动识别 id.secret Key;按量与 Coding Plan)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: glmClaudeCandidates(),\n\t\tRoutes: standardProviderRoutes(\"claude\", glmClaudeCandidates(), glmOpenAICandidates()),\n\t},\n\tcompshareProviderTemplate(),\n\tnewProviderTemplate(\n\t\t\"sensenova\",\n\t\t\"SenseNova\",\n\t\t\"商汤日日新官方 API\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://token.sensenova.cn\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://token.sensenova.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"minimax\",\n\t\t\"MiniMax\",\n\t\t\"MiniMax 官方 API\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.minimaxi.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.minimax.chat/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"dashscope\",\n\t\t\"阿里云 DashScope\",\n\t\t\"阿里云百炼按量、Coding Plan 与 Token Plan\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", dashScopeClaudeCandidates(), dashScopeOpenAICandidates()),\n\t),\n\tnewProviderTemplateWithAliases(\n\t\t\"opencode-zen\",\n\t\t[]string{\"opencode-go\"},\n\t\t\"OpenCode Zen / Go\",\n\t\t\"OpenCode 官方 Zen 按量与 Go 订阅模型网关\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"openai\", openCodeCandidates(), openCodeCandidates()),\n\t),\n\tnewProviderTemplate(\n\t\t\"tencent-lkeap\",\n\t\t\"腾讯云 TokenHub\",\n\t\t\"腾讯云大模型 Token Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/v3\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"qianfan\",\n\t\t\"百度千帆 Coding Plan\",\n\t\t\"百度智能云千帆 Coding Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://qianfan.baidubce.com/anthropic/coding\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://qianfan.baidubce.com/v2/coding#\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"xfyun\",\n\t\t\"讯飞星辰\",\n\t\t\"科大讯飞星辰 MaaS Coding Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\txfYunRoutes(),\n\t),\n\tnewProviderTemplate(\n\t\t\"openrouter\",\n\t\t\"OpenRouter\",\n\t\t\"OpenRouter 多模型聚合平台\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://openrouter.ai/api\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://openrouter.ai/api/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"modelscope\",\n\t\t\"ModelScope 魔搭\",\n\t\t\"阿里 ModelScope 官方推理服务\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api-inference.modelscope.cn\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api-inference.modelscope.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"originrouter\",\n\t\t\"极易云 OriginRouter\",\n\t\t\"极易云多模型 API 转发平台\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.easytransnote.com/coding\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.easytransnote.com/coding/v1\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n}\n\nvar zhipuAPIKeyPattern = regexp.MustCompile(`^[A-Za-z0-9]{20,}\\.[A-Za-z0-9]{10,}$`)\n\nfunc standardProviderRoutes(messagesServiceType string, messagesCandidates, openAICandidates []ProviderCandidate) []ProviderRoute {\n\tmessagesDescription := \"Claude Messages 原生 Anthropic 兼容入口\"\n\tif messagesServiceType == \"openai\" {\n\t\tmessagesDescription = \"Claude Messages 请求转换到 OpenAI Chat Completions\"\n\t}\n\treturn []ProviderRoute{\n\t\t{\n\t\t\tChannelKind: \"messages\",\n\t\t\tServiceType: messagesServiceType,\n\t\t\tDescription: messagesDescription,\n\t\t\tCandidates: messagesCandidates,\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"chat\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\tCandidates: openAICandidates,\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"responses\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\tCandidates: openAICandidates,\n\t\t},\n\t}\n}\n\nfunc newProviderTemplate(providerID, displayName, description, originType, originTier string, routes []ProviderRoute) ProviderTemplate {\n\treturn newProviderTemplateWithAliases(providerID, nil, displayName, description, originType, originTier, routes)\n}\n\nfunc newProviderTemplateWithAliases(providerID string, aliases []string, displayName, description, originType, originTier string, routes []ProviderRoute) ProviderTemplate {\n\ttmpl := ProviderTemplate{\n\t\tProviderID: providerID,\n\t\tAliases: aliases,\n\t\tDisplayName: displayName,\n\t\tDescription: description,\n\t\tOriginType: originType,\n\t\tOriginTier: originTier,\n\t\tRoutes: routes,\n\t}\n\tif len(routes) > 0 {\n\t\ttmpl.ChannelKind = routes[0].ChannelKind\n\t\ttmpl.ServiceType = routes[0].ServiceType\n\t\ttmpl.Candidates = routes[0].Candidates\n\t}\n\treturn tmpl\n}\n\nfunc compshareProviderTemplate() ProviderTemplate {\n\ttmpl := newProviderTemplate(\n\t\t\"compshare\",\n\t\t\"优云智算套餐\",\n\t\t\"UCloud 优云智算模型套餐与聚合服务\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://cp.compshare.cn\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://cp.compshare.cn/v1\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t)\n\t// 优云智算控制台的套餐消耗次数;glm-5.2 当前为限时倍率。\n\ttmpl.ModelCostMultipliers = map[string]float64{\n\t\t\"glm-5.2\": 2,\n\t\t\"glm-5.1\": 6,\n\t\t\"MiniMax-M2.7\": 2,\n\t\t\"kimi-k2.6\": 5,\n\t\t\"deepseek-ai/DeepSeek-V3.2\": 1,\n\t\t\"deepseek-v4-flash\": 1,\n\t}\n\ttmpl.ModelQualityPriorities = map[string]int{\n\t\t\"glm-5.1\": 2,\n\t\t\"kimi-k2.6\": 1,\n\t}\n\treturn tmpl\n}\n\nfunc deepseekClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com/anthropic\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\nfunc deepseekOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\n// deepseekResponsesCandidates DeepSeek 原生 Responses 入口。\n// 官方 base_url 为 https://api.deepseek.com,端点为标准 /v1/responses。\nfunc deepseekResponsesCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com/v1/responses\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\nfunc glmClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t}\n}\n\nfunc glmOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t// # 表示该路径已经是版本根,调用方不得再自动补 /v1。\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/paas/v4#\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/coding/paas/v4#\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n\nfunc kimiClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.moonshot.cn/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://api.moonshot.ai/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t\t{BaseURL: \"https://api.kimi.com/coding\", PlanTag: \"coding_plan\", Region: \"global\", Priority: 2},\n\t}\n}\n\nfunc kimiOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.moonshot.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://api.moonshot.ai/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t\t{BaseURL: \"https://api.kimi.com/coding/v1\", PlanTag: \"coding_plan\", Region: \"global\", Priority: 2},\n\t}\n}\n\nfunc dashScopeClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://dashscope.aliyuncs.com/apps/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://coding.dashscope.aliyuncs.com/apps/anthropic\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 2},\n\t}\n}\n\nfunc dashScopeOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://dashscope.aliyuncs.com/compatible-mode/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://coding.dashscope.aliyuncs.com/v1\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 2},\n\t}\n}\n\nfunc openCodeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://opencode.ai/zen/go/v1\", PlanTag: \"subscription\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://opencode.ai/zen/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t}\n}\n\nfunc xfYunRoutes() []ProviderRoute {\n\treturn []ProviderRoute{\n\t\t{\n\t\t\tChannelKind: \"messages\",\n\t\t\tServiceType: \"claude\",\n\t\t\tDescription: \"Claude Messages Coding Plan 入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/anthropic\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"chat\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"OpenAI Chat Completions Coding Plan 入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v2\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"responses\",\n\t\t\tServiceType: \"responses\",\n\t\t\tDescription: \"OpenAI Responses Coding Plan 原生入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v1/responses\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t}\n}\n\n// ListProviderTemplates 返回所有内置 provider 模板。\nfunc ListProviderTemplates() []ProviderTemplate {\n\tout := make([]ProviderTemplate, len(builtinProviderTemplates))\n\tcopy(out, builtinProviderTemplates)\n\treturn out\n}\n\n// GetProviderTemplate 按 providerId 查找模板,未找到返回 (nil, false)。\nfunc GetProviderTemplate(providerID string) (*ProviderTemplate, bool) {\n\tproviderID = strings.ToLower(strings.TrimSpace(providerID))\n\tfor i := range builtinProviderTemplates {\n\t\ttmpl := &builtinProviderTemplates[i]\n\t\tif strings.EqualFold(tmpl.ProviderID, providerID) {\n\t\t\treturn tmpl, true\n\t\t}\n\t\tfor _, alias := range tmpl.Aliases {\n\t\t\tif strings.EqualFold(alias, providerID) {\n\t\t\t\treturn tmpl, true\n\t\t\t}\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ModelCostMultiplierForModel 返回 provider 套餐内指定模型的相对消耗倍率。\n// 精确 ID 优先,随后按最长通配符匹配;非法、负数或非有限值视为未知。\nfunc (t *ProviderTemplate) ModelCostMultiplierForModel(modelID string) (float64, bool) {\n\tif t == nil {\n\t\treturn 0, false\n\t}\n\tmultiplier, _, ok := resolvePatternValueFold(modelID, t.ModelCostMultipliers)\n\tif !ok || multiplier < 0 || math.IsNaN(multiplier) || math.IsInf(multiplier, 0) {\n\t\treturn 0, false\n\t}\n\treturn multiplier, true\n}\n\n// ModelQualityPriorityForModel 返回 provider 已确认的同档模型能力优先级。\nfunc (t *ProviderTemplate) ModelQualityPriorityForModel(modelID string) (int, bool) {\n\tif t == nil {\n\t\treturn 0, false\n\t}\n\tpriority, _, ok := resolvePatternValueFold(modelID, t.ModelQualityPriorities)\n\tif !ok || priority <= 0 {\n\t\treturn 0, false\n\t}\n\treturn priority, true\n}\n\n// InferProviderIDFromBaseURL 仅按已知模板候选端点识别 provider。\n// 使用最长路径匹配,避免把仅承载同名模型的其他中转站误判为该渠道。\nfunc InferProviderIDFromBaseURL(baseURL string) (string, bool) {\n\ttarget, err := url.Parse(strings.TrimSuffix(strings.TrimSpace(baseURL), \"#\"))\n\tif err != nil || target.Hostname() == \"\" {\n\t\treturn \"\", false\n\t}\n\ttargetPath := strings.TrimRight(target.EscapedPath(), \"/\")\n\tbestProvider := \"\"\n\tbestPathLen := -1\n\tfor _, tmpl := range builtinProviderTemplates {\n\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\tfor _, candidate := range route.Candidates {\n\t\t\t\tcandidateURL, parseErr := url.Parse(strings.TrimSpace(candidate.BaseURL))\n\t\t\t\tif parseErr != nil || !sameURLServer(target, candidateURL) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tcandidatePath := strings.TrimRight(candidateURL.EscapedPath(), \"/\")\n\t\t\t\tif candidatePath != \"\" && targetPath != candidatePath && !strings.HasPrefix(targetPath, candidatePath+\"/\") {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif len(candidatePath) > bestPathLen {\n\t\t\t\t\tbestProvider = tmpl.ProviderID\n\t\t\t\t\tbestPathLen = len(candidatePath)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn bestProvider, bestProvider != \"\"\n}\n\n// InferProviderIDFromAPIKey 仅识别具有官方唯一格式的 API Key。\n// sk- 等共享格式无法可靠区分 provider,因此不会参与推断。\nfunc InferProviderIDFromAPIKey(apiKey string) (string, bool) {\n\tif zhipuAPIKeyPattern.MatchString(strings.TrimSpace(apiKey)) {\n\t\treturn \"glm\", true\n\t}\n\treturn \"\", false\n}\n\nfunc sameURLServer(left, right *url.URL) bool {\n\tif left == nil || right == nil || !strings.EqualFold(left.Hostname(), right.Hostname()) {\n\t\treturn false\n\t}\n\treturn effectiveURLPort(left) == effectiveURLPort(right)\n}\n\nfunc effectiveURLPort(value *url.URL) string {\n\tif value == nil {\n\t\treturn \"\"\n\t}\n\tif port := value.Port(); port != \"\" {\n\t\treturn port\n\t}\n\tswitch strings.ToLower(value.Scheme) {\n\tcase \"https\":\n\t\treturn \"443\"\n\tcase \"http\":\n\t\treturn \"80\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// AutoAddRoutes 返回 provider 快速添加时需要创建的渠道 route。\nfunc (t *ProviderTemplate) AutoAddRoutes() []ProviderRoute {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tif len(t.Routes) > 0 {\n\t\tout := make([]ProviderRoute, len(t.Routes))\n\t\tcopy(out, t.Routes)\n\t\treturn out\n\t}\n\tif t.ChannelKind == \"\" || t.ServiceType == \"\" {\n\t\treturn nil\n\t}\n\treturn []ProviderRoute{{\n\t\tChannelKind: t.ChannelKind,\n\t\tServiceType: t.ServiceType,\n\t\tCandidates: append([]ProviderCandidate(nil), t.Candidates...),\n\t}}\n}\n\n// SupportsChannelKind 判断 provider 是否可从指定渠道页发起快速添加。\nfunc (t *ProviderTemplate) SupportsChannelKind(kind string) bool {\n\tfor _, route := range t.AutoAddRoutes() {\n\t\tif route.ChannelKind == kind {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// CandidatesForKey 按 API Key 前缀返回默认 route 的候选 baseURL 顺序。\n//\n// 规则(对应用户决策):\n// 1. key 前缀命中某 PlanTag → 该 plan 的候选优先(按 Priority 升序),其余候选追加在后作为回退\n// 2. 前缀不匹配任何规则(或模板无前缀规则)→ 返回全部候选(按 Priority 升序)\n//\n// 返回的候选已按探测顺序排列,调用方依次探测,命中即用。\nfunc (t *ProviderTemplate) CandidatesForKey(apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\treturn t.CandidatesForRouteKey(ProviderRoute{Candidates: t.Candidates}, apiKey)\n}\n\n// CandidatesForRouteKey 按 API Key 前缀返回指定 route 的候选 baseURL 顺序。\nfunc (t *ProviderTemplate) CandidatesForRouteKey(route ProviderRoute, apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tmatchedPlan := \"\"\n\tfor _, rule := range t.KeyPrefixRules {\n\t\tif rule.Prefix != \"\" && strings.HasPrefix(apiKey, rule.Prefix) {\n\t\t\tmatchedPlan = rule.PlanTag\n\t\t\tbreak\n\t\t}\n\t}\n\n\tsortByPriority := func(list []ProviderCandidate) []ProviderCandidate {\n\t\tsorted := make([]ProviderCandidate, len(list))\n\t\tcopy(sorted, list)\n\t\t// 稳定插入排序(候选数量极少,无需 sort 包)\n\t\tfor i := 1; i < len(sorted); i++ {\n\t\t\tfor j := i; j > 0 && sorted[j].Priority < sorted[j-1].Priority; j-- {\n\t\t\t\tsorted[j], sorted[j-1] = sorted[j-1], sorted[j]\n\t\t\t}\n\t\t}\n\t\treturn sorted\n\t}\n\n\tif matchedPlan == \"\" {\n\t\treturn sortByPriority(route.Candidates)\n\t}\n\n\tpreferred := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfallback := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfor _, cand := range route.Candidates {\n\t\tif cand.PlanTag == matchedPlan {\n\t\t\tpreferred = append(preferred, cand)\n\t\t} else {\n\t\t\tfallback = append(fallback, cand)\n\t\t}\n\t}\n\treturn append(sortByPriority(preferred), sortByPriority(fallback)...)\n}\n\nfunc mimoClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc mimoChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc volcenginePlanClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n\nfunc volcenginePlanChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n"}, "e972d82e6b4b3edf23f948edbd0db2d4c80db655": {"t": 1785809885.431004, "n": 25135, "h": "4b8622458f235472", "c": "package config\n\nimport (\n\t\"math\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n)\n\n// ProviderTemplate 描述一个已知 provider 的模板化添加配置。\n//\n// 设计目标:用户只需选择 provider + 输入 API Key,系统按 key 前缀选候选 baseURL,\n// 探测验证可用性后自动创建渠道,无需手填 baseURL / 选协议 / 配兼容开关。\n//\n// baseURL 按上游原生协议区分端点:Claude 请求优先走原生 Claude 入口;\n// Chat / Codex Responses 请求走 OpenAI Chat 兼容入口,由后端做协议转换。\n// ProviderTemplate 只描述来源和端点,不再承载 channel-presets 兼容开关;\n// autoManaged 渠道的模型与能力差异由后端智能调度/ModelResolver 处理。\ntype ProviderTemplate struct {\n\tProviderID string `json:\"providerId\"` // \"mimo\" / \"deepseek\" / ...\n\tAliases []string `json:\"aliases,omitempty\"`\n\tDisplayName string `json:\"displayName\"` // \"小米 MiMo\"\n\tDescription string `json:\"description\"` // key 前缀说明等\n\tChannelKind string `json:\"channelKind\"` // 默认 route 的渠道类型,兼容旧前端\n\tServiceType string `json:\"serviceType\"` // 默认 route 的服务类型,兼容旧前端\n\tOriginType string `json:\"originType\"` // official_api / official_token_plan / relay\n\tOriginTier string `json:\"originTier\"` // first / second\n\tKeyPrefixRules []KeyPrefixRule `json:\"keyPrefixRules\"` // key 前缀 → plan 判别\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 默认 route 的候选 baseURL\n\tRoutes []ProviderRoute `json:\"routes,omitempty\"`\n\t// ModelCostMultipliers 描述同一 provider 套餐内每次模型调用的相对消耗。\n\t// key 支持模型精确 ID 或通配符;值越低越省,只在质量与实测表现相同后参与选优。\n\tModelCostMultipliers map[string]float64 `json:\"modelCostMultipliers,omitempty\"`\n\t// ModelQualityPriorities 描述 provider 已确认的同质量档模型能力顺序,值越高越强。\n\t// 该映射允许不完整;解析器仅在同档候选全部命中时比较,避免未知模型被无依据降权。\n\tModelQualityPriorities map[string]int `json:\"modelQualityPriorities,omitempty\"`\n}\n\n// ProviderRoute 描述同一 provider 在某个 CCX 渠道协议下使用的原生上游入口。\ntype ProviderRoute struct {\n\tChannelKind string `json:\"channelKind\"` // \"messages\" / \"chat\" / \"responses\"\n\tServiceType string `json:\"serviceType\"` // \"claude\" / \"openai\" / \"responses\"\n\tDescription string `json:\"description,omitempty\"` // route 说明,仅展示/诊断\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 该 route 的候选 baseURL\n\t// SupportedModels 限定该 route 端点实际接受的模型,语义与 UpstreamConfig.SupportedModels 一致\n\t// (支持精确匹配、prefix* / *suffix / *contains*,以及 ! 前缀排除)。\n\t//\n\t// 仅在上游同一 baseURL 下不同协议端点的模型支持范围不一致时才需要填写:\n\t// 例如 DeepSeek 的 /v1/responses 只接受 deepseek-v4-flash,而 /v1/chat/completions\n\t// 同时接受 flash 与 pro。留空表示该 route 不额外限制模型。\n\tSupportedModels []string `json:\"supportedModels,omitempty\"`\n}\n\n// KeyPrefixRule 按 API Key 前缀判别 plan 类型。\ntype KeyPrefixRule struct {\n\tPrefix string `json:\"prefix\"` // \"sk-\" / \"tp-\"\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\"\n}\n\n// ProviderCandidate 一个候选 baseURL 及其归属信息。\ntype ProviderCandidate struct {\n\tBaseURL string `json:\"baseUrl\"` // 完整 baseURL,如 https://api.xiaomimimo.com/anthropic\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\" / \"\"\n\tRegion string `json:\"region\"` // \"cn\" / \"sgp\" / \"ams\" / \"global\" / \"\"\n\tPriority int `json:\"priority\"` // 同 plan 内探测优先级(数字越小越先探测)\n}\n\n// builtinProviderTemplates 编译期内置的已知 provider 模板。\n//\n// URL 来源(2026-07 联网核实官方文档):\n// - MiMo: https://mimo.mi.com/docs(按量 sk- / Token Plan tp-,TP 分 cn/sgp/ams 三区域集群)\n// - DeepSeek: https://api-docs.deepseek.com(Anthropic 兼容 /anthropic)\n// - Kimi: https://api.moonshot.ai/anthropic(全球)/ https://api.moonshot.cn/anthropic(中国)\n// - GLM: https://open.bigmodel.cn/api/anthropic(Claude)与 /api/paas/v4(OpenAI)\n// - 火山方舟: https://ark.cn-beijing.volces.com/api/plan(Agent Plan)与 /api/coding(Coding Plan)\n//\n// Claude route 的 baseURL 使用 Anthropic 兼容入口且不带 /v1(claude provider 会自动补 /v1/messages)。\n// Chat/Responses route 使用 OpenAI Chat 兼容入口,由 provider 自动补协议端点。\nvar builtinProviderTemplates = []ProviderTemplate{\n\t{\n\t\tProviderID: \"mimo\",\n\t\tDisplayName: \"小米 MiMo\",\n\t\tDescription: \"sk- 按量付费 / tp- Token Plan 订阅(Claude 走 /anthropic,Chat/Codex/Gemini 走 /v1)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"sk-\", PlanTag: \"payg\"},\n\t\t\t{Prefix: \"tp-\", PlanTag: \"token_plan\"},\n\t\t},\n\t\tCandidates: mimoClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages 原生 Anthropic 兼容入口\",\n\t\t\t\tCandidates: mimoClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"deepseek\",\n\t\tDisplayName: \"DeepSeek\",\n\t\tDescription: \"DeepSeek 官方 API(Claude Messages、OpenAI Chat 与 Responses 三协议原生兼容;Responses 仅支持 deepseek-v4-flash)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: deepseekClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages Anthropic 兼容入口\",\n\t\t\t\tCandidates: deepseekClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: deepseekOpenAICandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"responses\",\n\t\t\t\tDescription: \"OpenAI Responses 原生入口(仅 deepseek-v4-flash;v4-pro 预计 2026-08 初支持)\",\n\t\t\t\tCandidates: deepseekResponsesCandidates(),\n\t\t\t\t// 上游对不支持的模型返回 400 而非忽略,因此在渠道层就限定为正向白名单:\n\t\t\t\t// 未知新模型不会被误放进该端点,v4-pro 上线后在此追加即可。\n\t\t\t\tSupportedModels: []string{\"deepseek-v4-flash\"},\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"volcengine\",\n\t\tAliases: []string{\"volc-ark\"},\n\t\tDisplayName: \"火山方舟 Agent/Coding Plan\",\n\t\tDescription: \"ark- 套餐推理 Key。Agent Plan 支持 AFP 感知路由(详见 volcengine_afp_pricing.go);Coding Plan 计费规则未核验,仅使用通用定价。模型发现需为每个 Key 绑定火山云 Access Key。\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"ark-\", PlanTag: \"personal_plan\"},\n\t\t},\n\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Agent/Coding Plan Claude Messages 入口\",\n\t\t\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Agent/Coding Plan OpenAI Chat Completions 入口\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"kimi\",\n\t\tAliases: []string{\"kimi-code\"},\n\t\tDisplayName: \"Kimi (Moonshot)\",\n\t\tDescription: \"Moonshot Kimi 官方 API(按量与 Coding Plan,自动判别可用入口)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: kimiClaudeCandidates(),\n\t\tRoutes: standardProviderRoutes(\"claude\", kimiClaudeCandidates(), kimiOpenAICandidates()),\n\t},\n\t{\n\t\tProviderID: \"glm\",\n\t\tDisplayName: \"智谱 GLM\",\n\t\tDescription: \"智谱 GLM 官方 API(自动识别 id.secret Key;按量与 Coding Plan)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: glmClaudeCandidates(),\n\t\tRoutes: standardProviderRoutes(\"claude\", glmClaudeCandidates(), glmOpenAICandidates()),\n\t},\n\tcompshareProviderTemplate(),\n\tnewProviderTemplate(\n\t\t\"sensenova\",\n\t\t\"SenseNova\",\n\t\t\"商汤日日新官方 API\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://token.sensenova.cn\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://token.sensenova.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"minimax\",\n\t\t\"MiniMax\",\n\t\t\"MiniMax 官方 API\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.minimaxi.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.minimax.chat/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"dashscope\",\n\t\t\"阿里云 DashScope\",\n\t\t\"阿里云百炼按量、Coding Plan 与 Token Plan\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", dashScopeClaudeCandidates(), dashScopeOpenAICandidates()),\n\t),\n\tnewProviderTemplateWithAliases(\n\t\t\"opencode-zen\",\n\t\t[]string{\"opencode-go\"},\n\t\t\"OpenCode Zen / Go\",\n\t\t\"OpenCode 官方 Zen 按量与 Go 订阅模型网关\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"openai\", openCodeCandidates(), openCodeCandidates()),\n\t),\n\tnewProviderTemplate(\n\t\t\"tencent-lkeap\",\n\t\t\"腾讯云 TokenHub\",\n\t\t\"腾讯云大模型 Token Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/v3\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"qianfan\",\n\t\t\"百度千帆 Coding Plan\",\n\t\t\"百度智能云千帆 Coding Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://qianfan.baidubce.com/anthropic/coding\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://qianfan.baidubce.com/v2/coding#\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"xfyun\",\n\t\t\"讯飞星辰\",\n\t\t\"科大讯飞星辰 MaaS Coding Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\txfYunRoutes(),\n\t),\n\tnewProviderTemplate(\n\t\t\"openrouter\",\n\t\t\"OpenRouter\",\n\t\t\"OpenRouter 多模型聚合平台\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://openrouter.ai/api\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://openrouter.ai/api/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"modelscope\",\n\t\t\"ModelScope 魔搭\",\n\t\t\"阿里 ModelScope 官方推理服务\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api-inference.modelscope.cn\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api-inference.modelscope.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"originrouter\",\n\t\t\"极易云 OriginRouter\",\n\t\t\"极易云多模型 API 转发平台\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.easytransnote.com/coding\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.easytransnote.com/coding/v1\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n}\n\nvar zhipuAPIKeyPattern = regexp.MustCompile(`^[A-Za-z0-9]{20,}\\.[A-Za-z0-9]{10,}$`)\n\nfunc standardProviderRoutes(messagesServiceType string, messagesCandidates, openAICandidates []ProviderCandidate) []ProviderRoute {\n\tmessagesDescription := \"Claude Messages 原生 Anthropic 兼容入口\"\n\tif messagesServiceType == \"openai\" {\n\t\tmessagesDescription = \"Claude Messages 请求转换到 OpenAI Chat Completions\"\n\t}\n\treturn []ProviderRoute{\n\t\t{\n\t\t\tChannelKind: \"messages\",\n\t\t\tServiceType: messagesServiceType,\n\t\t\tDescription: messagesDescription,\n\t\t\tCandidates: messagesCandidates,\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"chat\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\tCandidates: openAICandidates,\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"responses\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\tCandidates: openAICandidates,\n\t\t},\n\t}\n}\n\nfunc newProviderTemplate(providerID, displayName, description, originType, originTier string, routes []ProviderRoute) ProviderTemplate {\n\treturn newProviderTemplateWithAliases(providerID, nil, displayName, description, originType, originTier, routes)\n}\n\nfunc newProviderTemplateWithAliases(providerID string, aliases []string, displayName, description, originType, originTier string, routes []ProviderRoute) ProviderTemplate {\n\ttmpl := ProviderTemplate{\n\t\tProviderID: providerID,\n\t\tAliases: aliases,\n\t\tDisplayName: displayName,\n\t\tDescription: description,\n\t\tOriginType: originType,\n\t\tOriginTier: originTier,\n\t\tRoutes: routes,\n\t}\n\tif len(routes) > 0 {\n\t\ttmpl.ChannelKind = routes[0].ChannelKind\n\t\ttmpl.ServiceType = routes[0].ServiceType\n\t\ttmpl.Candidates = routes[0].Candidates\n\t}\n\treturn tmpl\n}\n\nfunc compshareProviderTemplate() ProviderTemplate {\n\ttmpl := newProviderTemplate(\n\t\t\"compshare\",\n\t\t\"优云智算套餐\",\n\t\t\"UCloud 优云智算模型套餐与聚合服务\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://cp.compshare.cn\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://cp.compshare.cn/v1\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t)\n\t// 优云智算控制台的套餐消耗次数;glm-5.2 当前为限时倍率。\n\ttmpl.ModelCostMultipliers = map[string]float64{\n\t\t\"glm-5.2\": 2,\n\t\t\"glm-5.1\": 6,\n\t\t\"MiniMax-M2.7\": 2,\n\t\t\"kimi-k2.6\": 5,\n\t\t\"deepseek-ai/DeepSeek-V3.2\": 1,\n\t\t\"deepseek-v4-flash\": 1,\n\t}\n\ttmpl.ModelQualityPriorities = map[string]int{\n\t\t\"glm-5.1\": 2,\n\t\t\"kimi-k2.6\": 1,\n\t}\n\treturn tmpl\n}\n\nfunc deepseekClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com/anthropic\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\nfunc deepseekOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\n// deepseekResponsesCandidates DeepSeek 原生 Responses 入口。\n// 官方 base_url 为 https://api.deepseek.com,端点为标准 /v1/responses。\nfunc deepseekResponsesCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com/v1/responses\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\nfunc glmClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t}\n}\n\nfunc glmOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t// # 表示该路径已经是版本根,调用方不得再自动补 /v1。\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/paas/v4#\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/coding/paas/v4#\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n\nfunc kimiClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.moonshot.cn/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://api.moonshot.ai/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t\t{BaseURL: \"https://api.kimi.com/coding\", PlanTag: \"coding_plan\", Region: \"global\", Priority: 2},\n\t}\n}\n\nfunc kimiOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.moonshot.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://api.moonshot.ai/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t\t{BaseURL: \"https://api.kimi.com/coding/v1\", PlanTag: \"coding_plan\", Region: \"global\", Priority: 2},\n\t}\n}\n\nfunc dashScopeClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://dashscope.aliyuncs.com/apps/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://coding.dashscope.aliyuncs.com/apps/anthropic\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 2},\n\t}\n}\n\nfunc dashScopeOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://dashscope.aliyuncs.com/compatible-mode/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://coding.dashscope.aliyuncs.com/v1\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 2},\n\t}\n}\n\nfunc openCodeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://opencode.ai/zen/go/v1\", PlanTag: \"subscription\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://opencode.ai/zen/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t}\n}\n\nfunc xfYunRoutes() []ProviderRoute {\n\treturn []ProviderRoute{\n\t\t{\n\t\t\tChannelKind: \"messages\",\n\t\t\tServiceType: \"claude\",\n\t\t\tDescription: \"Claude Messages Coding Plan 入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/anthropic\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"chat\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"OpenAI Chat Completions Coding Plan 入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v2\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"responses\",\n\t\t\tServiceType: \"responses\",\n\t\t\tDescription: \"OpenAI Responses Coding Plan 原生入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v1/responses\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t}\n}\n\n// ListProviderTemplates 返回所有内置 provider 模板。\nfunc ListProviderTemplates() []ProviderTemplate {\n\tout := make([]ProviderTemplate, len(builtinProviderTemplates))\n\tcopy(out, builtinProviderTemplates)\n\treturn out\n}\n\n// GetProviderTemplate 按 providerId 查找模板,未找到返回 (nil, false)。\nfunc GetProviderTemplate(providerID string) (*ProviderTemplate, bool) {\n\tproviderID = strings.ToLower(strings.TrimSpace(providerID))\n\tfor i := range builtinProviderTemplates {\n\t\ttmpl := &builtinProviderTemplates[i]\n\t\tif strings.EqualFold(tmpl.ProviderID, providerID) {\n\t\t\treturn tmpl, true\n\t\t}\n\t\tfor _, alias := range tmpl.Aliases {\n\t\t\tif strings.EqualFold(alias, providerID) {\n\t\t\t\treturn tmpl, true\n\t\t\t}\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ModelCostMultiplierForModel 返回 provider 套餐内指定模型的相对消耗倍率。\n// 精确 ID 优先,随后按最长通配符匹配;非法、负数或非有限值视为未知。\nfunc (t *ProviderTemplate) ModelCostMultiplierForModel(modelID string) (float64, bool) {\n\tif t == nil {\n\t\treturn 0, false\n\t}\n\tmultiplier, _, ok := resolvePatternValueFold(modelID, t.ModelCostMultipliers)\n\tif !ok || multiplier < 0 || math.IsNaN(multiplier) || math.IsInf(multiplier, 0) {\n\t\treturn 0, false\n\t}\n\treturn multiplier, true\n}\n\n// ModelQualityPriorityForModel 返回 provider 已确认的同档模型能力优先级。\nfunc (t *ProviderTemplate) ModelQualityPriorityForModel(modelID string) (int, bool) {\n\tif t == nil {\n\t\treturn 0, false\n\t}\n\tpriority, _, ok := resolvePatternValueFold(modelID, t.ModelQualityPriorities)\n\tif !ok || priority <= 0 {\n\t\treturn 0, false\n\t}\n\treturn priority, true\n}\n\n// InferProviderIDFromBaseURL 仅按已知模板候选端点识别 provider。\n// 使用最长路径匹配,避免把仅承载同名模型的其他中转站误判为该渠道。\nfunc InferProviderIDFromBaseURL(baseURL string) (string, bool) {\n\ttarget, err := url.Parse(strings.TrimSuffix(strings.TrimSpace(baseURL), \"#\"))\n\tif err != nil || target.Hostname() == \"\" {\n\t\treturn \"\", false\n\t}\n\ttargetPath := strings.TrimRight(target.EscapedPath(), \"/\")\n\tbestProvider := \"\"\n\tbestPathLen := -1\n\tfor _, tmpl := range builtinProviderTemplates {\n\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\tfor _, candidate := range route.Candidates {\n\t\t\t\tcandidateURL, parseErr := url.Parse(strings.TrimSpace(candidate.BaseURL))\n\t\t\t\tif parseErr != nil || !sameURLServer(target, candidateURL) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tcandidatePath := strings.TrimRight(candidateURL.EscapedPath(), \"/\")\n\t\t\t\tif candidatePath != \"\" && targetPath != candidatePath && !strings.HasPrefix(targetPath, candidatePath+\"/\") {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif len(candidatePath) > bestPathLen {\n\t\t\t\t\tbestProvider = tmpl.ProviderID\n\t\t\t\t\tbestPathLen = len(candidatePath)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn bestProvider, bestProvider != \"\"\n}\n\n// InferProviderIDFromAPIKey 仅识别具有官方唯一格式的 API Key。\n// sk- 等共享格式无法可靠区分 provider,因此不会参与推断。\nfunc InferProviderIDFromAPIKey(apiKey string) (string, bool) {\n\tif zhipuAPIKeyPattern.MatchString(strings.TrimSpace(apiKey)) {\n\t\treturn \"glm\", true\n\t}\n\treturn \"\", false\n}\n\nfunc sameURLServer(left, right *url.URL) bool {\n\tif left == nil || right == nil || !strings.EqualFold(left.Hostname(), right.Hostname()) {\n\t\treturn false\n\t}\n\treturn effectiveURLPort(left) == effectiveURLPort(right)\n}\n\nfunc effectiveURLPort(value *url.URL) string {\n\tif value == nil {\n\t\treturn \"\"\n\t}\n\tif port := value.Port(); port != \"\" {\n\t\treturn port\n\t}\n\tswitch strings.ToLower(value.Scheme) {\n\tcase \"https\":\n\t\treturn \"443\"\n\tcase \"http\":\n\t\treturn \"80\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// AutoAddRoutes 返回 provider 快速添加时需要创建的渠道 route。\nfunc (t *ProviderTemplate) AutoAddRoutes() []ProviderRoute {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tif len(t.Routes) > 0 {\n\t\tout := make([]ProviderRoute, len(t.Routes))\n\t\tcopy(out, t.Routes)\n\t\treturn out\n\t}\n\tif t.ChannelKind == \"\" || t.ServiceType == \"\" {\n\t\treturn nil\n\t}\n\treturn []ProviderRoute{{\n\t\tChannelKind: t.ChannelKind,\n\t\tServiceType: t.ServiceType,\n\t\tCandidates: append([]ProviderCandidate(nil), t.Candidates...),\n\t}}\n}\n\n// SupportsChannelKind 判断 provider 是否可从指定渠道页发起快速添加。\nfunc (t *ProviderTemplate) SupportsChannelKind(kind string) bool {\n\tfor _, route := range t.AutoAddRoutes() {\n\t\tif route.ChannelKind == kind {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// CandidatesForKey 按 API Key 前缀返回默认 route 的候选 baseURL 顺序。\n//\n// 规则(对应用户决策):\n// 1. key 前缀命中某 PlanTag → 该 plan 的候选优先(按 Priority 升序),其余候选追加在后作为回退\n// 2. 前缀不匹配任何规则(或模板无前缀规则)→ 返回全部候选(按 Priority 升序)\n//\n// 返回的候选已按探测顺序排列,调用方依次探测,命中即用。\nfunc (t *ProviderTemplate) CandidatesForKey(apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\treturn t.CandidatesForRouteKey(ProviderRoute{Candidates: t.Candidates}, apiKey)\n}\n\n// CandidatesForRouteKey 按 API Key 前缀返回指定 route 的候选 baseURL 顺序。\nfunc (t *ProviderTemplate) CandidatesForRouteKey(route ProviderRoute, apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tmatchedPlan := \"\"\n\tfor _, rule := range t.KeyPrefixRules {\n\t\tif rule.Prefix != \"\" && strings.HasPrefix(apiKey, rule.Prefix) {\n\t\t\tmatchedPlan = rule.PlanTag\n\t\t\tbreak\n\t\t}\n\t}\n\n\tsortByPriority := func(list []ProviderCandidate) []ProviderCandidate {\n\t\tsorted := make([]ProviderCandidate, len(list))\n\t\tcopy(sorted, list)\n\t\t// 稳定插入排序(候选数量极少,无需 sort 包)\n\t\tfor i := 1; i < len(sorted); i++ {\n\t\t\tfor j := i; j > 0 && sorted[j].Priority < sorted[j-1].Priority; j-- {\n\t\t\t\tsorted[j], sorted[j-1] = sorted[j-1], sorted[j]\n\t\t\t}\n\t\t}\n\t\treturn sorted\n\t}\n\n\tif matchedPlan == \"\" {\n\t\treturn sortByPriority(route.Candidates)\n\t}\n\n\tpreferred := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfallback := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfor _, cand := range route.Candidates {\n\t\tif cand.PlanTag == matchedPlan {\n\t\t\tpreferred = append(preferred, cand)\n\t\t} else {\n\t\t\tfallback = append(fallback, cand)\n\t\t}\n\t}\n\treturn append(sortByPriority(preferred), sortByPriority(fallback)...)\n}\n\nfunc mimoClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc mimoChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc volcenginePlanClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n\nfunc volcenginePlanChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n"}, "4da6d77e15a5c2ac4bc15ce1ad1a68cfdbd2e2d0": {"t": 1785846835.0625682, "n": 25135, "h": "4b8622458f235472", "c": "package config\n\nimport (\n\t\"math\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n)\n\n// ProviderTemplate 描述一个已知 provider 的模板化添加配置。\n//\n// 设计目标:用户只需选择 provider + 输入 API Key,系统按 key 前缀选候选 baseURL,\n// 探测验证可用性后自动创建渠道,无需手填 baseURL / 选协议 / 配兼容开关。\n//\n// baseURL 按上游原生协议区分端点:Claude 请求优先走原生 Claude 入口;\n// Chat / Codex Responses 请求走 OpenAI Chat 兼容入口,由后端做协议转换。\n// ProviderTemplate 只描述来源和端点,不再承载 channel-presets 兼容开关;\n// autoManaged 渠道的模型与能力差异由后端智能调度/ModelResolver 处理。\ntype ProviderTemplate struct {\n\tProviderID string `json:\"providerId\"` // \"mimo\" / \"deepseek\" / ...\n\tAliases []string `json:\"aliases,omitempty\"`\n\tDisplayName string `json:\"displayName\"` // \"小米 MiMo\"\n\tDescription string `json:\"description\"` // key 前缀说明等\n\tChannelKind string `json:\"channelKind\"` // 默认 route 的渠道类型,兼容旧前端\n\tServiceType string `json:\"serviceType\"` // 默认 route 的服务类型,兼容旧前端\n\tOriginType string `json:\"originType\"` // official_api / official_token_plan / relay\n\tOriginTier string `json:\"originTier\"` // first / second\n\tKeyPrefixRules []KeyPrefixRule `json:\"keyPrefixRules\"` // key 前缀 → plan 判别\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 默认 route 的候选 baseURL\n\tRoutes []ProviderRoute `json:\"routes,omitempty\"`\n\t// ModelCostMultipliers 描述同一 provider 套餐内每次模型调用的相对消耗。\n\t// key 支持模型精确 ID 或通配符;值越低越省,只在质量与实测表现相同后参与选优。\n\tModelCostMultipliers map[string]float64 `json:\"modelCostMultipliers,omitempty\"`\n\t// ModelQualityPriorities 描述 provider 已确认的同质量档模型能力顺序,值越高越强。\n\t// 该映射允许不完整;解析器仅在同档候选全部命中时比较,避免未知模型被无依据降权。\n\tModelQualityPriorities map[string]int `json:\"modelQualityPriorities,omitempty\"`\n}\n\n// ProviderRoute 描述同一 provider 在某个 CCX 渠道协议下使用的原生上游入口。\ntype ProviderRoute struct {\n\tChannelKind string `json:\"channelKind\"` // \"messages\" / \"chat\" / \"responses\"\n\tServiceType string `json:\"serviceType\"` // \"claude\" / \"openai\" / \"responses\"\n\tDescription string `json:\"description,omitempty\"` // route 说明,仅展示/诊断\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 该 route 的候选 baseURL\n\t// SupportedModels 限定该 route 端点实际接受的模型,语义与 UpstreamConfig.SupportedModels 一致\n\t// (支持精确匹配、prefix* / *suffix / *contains*,以及 ! 前缀排除)。\n\t//\n\t// 仅在上游同一 baseURL 下不同协议端点的模型支持范围不一致时才需要填写:\n\t// 例如 DeepSeek 的 /v1/responses 只接受 deepseek-v4-flash,而 /v1/chat/completions\n\t// 同时接受 flash 与 pro。留空表示该 route 不额外限制模型。\n\tSupportedModels []string `json:\"supportedModels,omitempty\"`\n}\n\n// KeyPrefixRule 按 API Key 前缀判别 plan 类型。\ntype KeyPrefixRule struct {\n\tPrefix string `json:\"prefix\"` // \"sk-\" / \"tp-\"\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\"\n}\n\n// ProviderCandidate 一个候选 baseURL 及其归属信息。\ntype ProviderCandidate struct {\n\tBaseURL string `json:\"baseUrl\"` // 完整 baseURL,如 https://api.xiaomimimo.com/anthropic\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\" / \"\"\n\tRegion string `json:\"region\"` // \"cn\" / \"sgp\" / \"ams\" / \"global\" / \"\"\n\tPriority int `json:\"priority\"` // 同 plan 内探测优先级(数字越小越先探测)\n}\n\n// builtinProviderTemplates 编译期内置的已知 provider 模板。\n//\n// URL 来源(2026-07 联网核实官方文档):\n// - MiMo: https://mimo.mi.com/docs(按量 sk- / Token Plan tp-,TP 分 cn/sgp/ams 三区域集群)\n// - DeepSeek: https://api-docs.deepseek.com(Anthropic 兼容 /anthropic)\n// - Kimi: https://api.moonshot.ai/anthropic(全球)/ https://api.moonshot.cn/anthropic(中国)\n// - GLM: https://open.bigmodel.cn/api/anthropic(Claude)与 /api/paas/v4(OpenAI)\n// - 火山方舟: https://ark.cn-beijing.volces.com/api/plan(Agent Plan)与 /api/coding(Coding Plan)\n//\n// Claude route 的 baseURL 使用 Anthropic 兼容入口且不带 /v1(claude provider 会自动补 /v1/messages)。\n// Chat/Responses route 使用 OpenAI Chat 兼容入口,由 provider 自动补协议端点。\nvar builtinProviderTemplates = []ProviderTemplate{\n\t{\n\t\tProviderID: \"mimo\",\n\t\tDisplayName: \"小米 MiMo\",\n\t\tDescription: \"sk- 按量付费 / tp- Token Plan 订阅(Claude 走 /anthropic,Chat/Codex/Gemini 走 /v1)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"sk-\", PlanTag: \"payg\"},\n\t\t\t{Prefix: \"tp-\", PlanTag: \"token_plan\"},\n\t\t},\n\t\tCandidates: mimoClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages 原生 Anthropic 兼容入口\",\n\t\t\t\tCandidates: mimoClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"deepseek\",\n\t\tDisplayName: \"DeepSeek\",\n\t\tDescription: \"DeepSeek 官方 API(Claude Messages、OpenAI Chat 与 Responses 三协议原生兼容;Responses 仅支持 deepseek-v4-flash)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: deepseekClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages Anthropic 兼容入口\",\n\t\t\t\tCandidates: deepseekClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: deepseekOpenAICandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"responses\",\n\t\t\t\tDescription: \"OpenAI Responses 原生入口(仅 deepseek-v4-flash;v4-pro 预计 2026-08 初支持)\",\n\t\t\t\tCandidates: deepseekResponsesCandidates(),\n\t\t\t\t// 上游对不支持的模型返回 400 而非忽略,因此在渠道层就限定为正向白名单:\n\t\t\t\t// 未知新模型不会被误放进该端点,v4-pro 上线后在此追加即可。\n\t\t\t\tSupportedModels: []string{\"deepseek-v4-flash\"},\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"volcengine\",\n\t\tAliases: []string{\"volc-ark\"},\n\t\tDisplayName: \"火山方舟 Agent/Coding Plan\",\n\t\tDescription: \"ark- 套餐推理 Key。Agent Plan 支持 AFP 感知路由(详见 volcengine_afp_pricing.go);Coding Plan 计费规则未核验,仅使用通用定价。模型发现需为每个 Key 绑定火山云 Access Key。\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"ark-\", PlanTag: \"personal_plan\"},\n\t\t},\n\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Agent/Coding Plan Claude Messages 入口\",\n\t\t\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Agent/Coding Plan OpenAI Chat Completions 入口\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"kimi\",\n\t\tAliases: []string{\"kimi-code\"},\n\t\tDisplayName: \"Kimi (Moonshot)\",\n\t\tDescription: \"Moonshot Kimi 官方 API(按量与 Coding Plan,自动判别可用入口)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: kimiClaudeCandidates(),\n\t\tRoutes: standardProviderRoutes(\"claude\", kimiClaudeCandidates(), kimiOpenAICandidates()),\n\t},\n\t{\n\t\tProviderID: \"glm\",\n\t\tDisplayName: \"智谱 GLM\",\n\t\tDescription: \"智谱 GLM 官方 API(自动识别 id.secret Key;按量与 Coding Plan)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: glmClaudeCandidates(),\n\t\tRoutes: standardProviderRoutes(\"claude\", glmClaudeCandidates(), glmOpenAICandidates()),\n\t},\n\tcompshareProviderTemplate(),\n\tnewProviderTemplate(\n\t\t\"sensenova\",\n\t\t\"SenseNova\",\n\t\t\"商汤日日新官方 API\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://token.sensenova.cn\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://token.sensenova.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"minimax\",\n\t\t\"MiniMax\",\n\t\t\"MiniMax 官方 API\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.minimaxi.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.minimax.chat/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"dashscope\",\n\t\t\"阿里云 DashScope\",\n\t\t\"阿里云百炼按量、Coding Plan 与 Token Plan\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", dashScopeClaudeCandidates(), dashScopeOpenAICandidates()),\n\t),\n\tnewProviderTemplateWithAliases(\n\t\t\"opencode-zen\",\n\t\t[]string{\"opencode-go\"},\n\t\t\"OpenCode Zen / Go\",\n\t\t\"OpenCode 官方 Zen 按量与 Go 订阅模型网关\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"openai\", openCodeCandidates(), openCodeCandidates()),\n\t),\n\tnewProviderTemplate(\n\t\t\"tencent-lkeap\",\n\t\t\"腾讯云 TokenHub\",\n\t\t\"腾讯云大模型 Token Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/v3\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"qianfan\",\n\t\t\"百度千帆 Coding Plan\",\n\t\t\"百度智能云千帆 Coding Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://qianfan.baidubce.com/anthropic/coding\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://qianfan.baidubce.com/v2/coding#\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"xfyun\",\n\t\t\"讯飞星辰\",\n\t\t\"科大讯飞星辰 MaaS Coding Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\txfYunRoutes(),\n\t),\n\tnewProviderTemplate(\n\t\t\"openrouter\",\n\t\t\"OpenRouter\",\n\t\t\"OpenRouter 多模型聚合平台\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://openrouter.ai/api\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://openrouter.ai/api/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"modelscope\",\n\t\t\"ModelScope 魔搭\",\n\t\t\"阿里 ModelScope 官方推理服务\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api-inference.modelscope.cn\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api-inference.modelscope.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"originrouter\",\n\t\t\"极易云 OriginRouter\",\n\t\t\"极易云多模型 API 转发平台\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.easytransnote.com/coding\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.easytransnote.com/coding/v1\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n}\n\nvar zhipuAPIKeyPattern = regexp.MustCompile(`^[A-Za-z0-9]{20,}\\.[A-Za-z0-9]{10,}$`)\n\nfunc standardProviderRoutes(messagesServiceType string, messagesCandidates, openAICandidates []ProviderCandidate) []ProviderRoute {\n\tmessagesDescription := \"Claude Messages 原生 Anthropic 兼容入口\"\n\tif messagesServiceType == \"openai\" {\n\t\tmessagesDescription = \"Claude Messages 请求转换到 OpenAI Chat Completions\"\n\t}\n\treturn []ProviderRoute{\n\t\t{\n\t\t\tChannelKind: \"messages\",\n\t\t\tServiceType: messagesServiceType,\n\t\t\tDescription: messagesDescription,\n\t\t\tCandidates: messagesCandidates,\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"chat\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\tCandidates: openAICandidates,\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"responses\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\tCandidates: openAICandidates,\n\t\t},\n\t}\n}\n\nfunc newProviderTemplate(providerID, displayName, description, originType, originTier string, routes []ProviderRoute) ProviderTemplate {\n\treturn newProviderTemplateWithAliases(providerID, nil, displayName, description, originType, originTier, routes)\n}\n\nfunc newProviderTemplateWithAliases(providerID string, aliases []string, displayName, description, originType, originTier string, routes []ProviderRoute) ProviderTemplate {\n\ttmpl := ProviderTemplate{\n\t\tProviderID: providerID,\n\t\tAliases: aliases,\n\t\tDisplayName: displayName,\n\t\tDescription: description,\n\t\tOriginType: originType,\n\t\tOriginTier: originTier,\n\t\tRoutes: routes,\n\t}\n\tif len(routes) > 0 {\n\t\ttmpl.ChannelKind = routes[0].ChannelKind\n\t\ttmpl.ServiceType = routes[0].ServiceType\n\t\ttmpl.Candidates = routes[0].Candidates\n\t}\n\treturn tmpl\n}\n\nfunc compshareProviderTemplate() ProviderTemplate {\n\ttmpl := newProviderTemplate(\n\t\t\"compshare\",\n\t\t\"优云智算套餐\",\n\t\t\"UCloud 优云智算模型套餐与聚合服务\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://cp.compshare.cn\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://cp.compshare.cn/v1\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t)\n\t// 优云智算控制台的套餐消耗次数;glm-5.2 当前为限时倍率。\n\ttmpl.ModelCostMultipliers = map[string]float64{\n\t\t\"glm-5.2\": 2,\n\t\t\"glm-5.1\": 6,\n\t\t\"MiniMax-M2.7\": 2,\n\t\t\"kimi-k2.6\": 5,\n\t\t\"deepseek-ai/DeepSeek-V3.2\": 1,\n\t\t\"deepseek-v4-flash\": 1,\n\t}\n\ttmpl.ModelQualityPriorities = map[string]int{\n\t\t\"glm-5.1\": 2,\n\t\t\"kimi-k2.6\": 1,\n\t}\n\treturn tmpl\n}\n\nfunc deepseekClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com/anthropic\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\nfunc deepseekOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\n// deepseekResponsesCandidates DeepSeek 原生 Responses 入口。\n// 官方 base_url 为 https://api.deepseek.com,端点为标准 /v1/responses。\nfunc deepseekResponsesCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com/v1/responses\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\nfunc glmClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t}\n}\n\nfunc glmOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t// # 表示该路径已经是版本根,调用方不得再自动补 /v1。\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/paas/v4#\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/coding/paas/v4#\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n\nfunc kimiClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.moonshot.cn/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://api.moonshot.ai/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t\t{BaseURL: \"https://api.kimi.com/coding\", PlanTag: \"coding_plan\", Region: \"global\", Priority: 2},\n\t}\n}\n\nfunc kimiOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.moonshot.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://api.moonshot.ai/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t\t{BaseURL: \"https://api.kimi.com/coding/v1\", PlanTag: \"coding_plan\", Region: \"global\", Priority: 2},\n\t}\n}\n\nfunc dashScopeClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://dashscope.aliyuncs.com/apps/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://coding.dashscope.aliyuncs.com/apps/anthropic\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 2},\n\t}\n}\n\nfunc dashScopeOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://dashscope.aliyuncs.com/compatible-mode/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://coding.dashscope.aliyuncs.com/v1\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 2},\n\t}\n}\n\nfunc openCodeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://opencode.ai/zen/go/v1\", PlanTag: \"subscription\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://opencode.ai/zen/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t}\n}\n\nfunc xfYunRoutes() []ProviderRoute {\n\treturn []ProviderRoute{\n\t\t{\n\t\t\tChannelKind: \"messages\",\n\t\t\tServiceType: \"claude\",\n\t\t\tDescription: \"Claude Messages Coding Plan 入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/anthropic\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"chat\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"OpenAI Chat Completions Coding Plan 入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v2\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"responses\",\n\t\t\tServiceType: \"responses\",\n\t\t\tDescription: \"OpenAI Responses Coding Plan 原生入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v1/responses\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t}\n}\n\n// ListProviderTemplates 返回所有内置 provider 模板。\nfunc ListProviderTemplates() []ProviderTemplate {\n\tout := make([]ProviderTemplate, len(builtinProviderTemplates))\n\tcopy(out, builtinProviderTemplates)\n\treturn out\n}\n\n// GetProviderTemplate 按 providerId 查找模板,未找到返回 (nil, false)。\nfunc GetProviderTemplate(providerID string) (*ProviderTemplate, bool) {\n\tproviderID = strings.ToLower(strings.TrimSpace(providerID))\n\tfor i := range builtinProviderTemplates {\n\t\ttmpl := &builtinProviderTemplates[i]\n\t\tif strings.EqualFold(tmpl.ProviderID, providerID) {\n\t\t\treturn tmpl, true\n\t\t}\n\t\tfor _, alias := range tmpl.Aliases {\n\t\t\tif strings.EqualFold(alias, providerID) {\n\t\t\t\treturn tmpl, true\n\t\t\t}\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ModelCostMultiplierForModel 返回 provider 套餐内指定模型的相对消耗倍率。\n// 精确 ID 优先,随后按最长通配符匹配;非法、负数或非有限值视为未知。\nfunc (t *ProviderTemplate) ModelCostMultiplierForModel(modelID string) (float64, bool) {\n\tif t == nil {\n\t\treturn 0, false\n\t}\n\tmultiplier, _, ok := resolvePatternValueFold(modelID, t.ModelCostMultipliers)\n\tif !ok || multiplier < 0 || math.IsNaN(multiplier) || math.IsInf(multiplier, 0) {\n\t\treturn 0, false\n\t}\n\treturn multiplier, true\n}\n\n// ModelQualityPriorityForModel 返回 provider 已确认的同档模型能力优先级。\nfunc (t *ProviderTemplate) ModelQualityPriorityForModel(modelID string) (int, bool) {\n\tif t == nil {\n\t\treturn 0, false\n\t}\n\tpriority, _, ok := resolvePatternValueFold(modelID, t.ModelQualityPriorities)\n\tif !ok || priority <= 0 {\n\t\treturn 0, false\n\t}\n\treturn priority, true\n}\n\n// InferProviderIDFromBaseURL 仅按已知模板候选端点识别 provider。\n// 使用最长路径匹配,避免把仅承载同名模型的其他中转站误判为该渠道。\nfunc InferProviderIDFromBaseURL(baseURL string) (string, bool) {\n\ttarget, err := url.Parse(strings.TrimSuffix(strings.TrimSpace(baseURL), \"#\"))\n\tif err != nil || target.Hostname() == \"\" {\n\t\treturn \"\", false\n\t}\n\ttargetPath := strings.TrimRight(target.EscapedPath(), \"/\")\n\tbestProvider := \"\"\n\tbestPathLen := -1\n\tfor _, tmpl := range builtinProviderTemplates {\n\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\tfor _, candidate := range route.Candidates {\n\t\t\t\tcandidateURL, parseErr := url.Parse(strings.TrimSpace(candidate.BaseURL))\n\t\t\t\tif parseErr != nil || !sameURLServer(target, candidateURL) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tcandidatePath := strings.TrimRight(candidateURL.EscapedPath(), \"/\")\n\t\t\t\tif candidatePath != \"\" && targetPath != candidatePath && !strings.HasPrefix(targetPath, candidatePath+\"/\") {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif len(candidatePath) > bestPathLen {\n\t\t\t\t\tbestProvider = tmpl.ProviderID\n\t\t\t\t\tbestPathLen = len(candidatePath)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn bestProvider, bestProvider != \"\"\n}\n\n// InferProviderIDFromAPIKey 仅识别具有官方唯一格式的 API Key。\n// sk- 等共享格式无法可靠区分 provider,因此不会参与推断。\nfunc InferProviderIDFromAPIKey(apiKey string) (string, bool) {\n\tif zhipuAPIKeyPattern.MatchString(strings.TrimSpace(apiKey)) {\n\t\treturn \"glm\", true\n\t}\n\treturn \"\", false\n}\n\nfunc sameURLServer(left, right *url.URL) bool {\n\tif left == nil || right == nil || !strings.EqualFold(left.Hostname(), right.Hostname()) {\n\t\treturn false\n\t}\n\treturn effectiveURLPort(left) == effectiveURLPort(right)\n}\n\nfunc effectiveURLPort(value *url.URL) string {\n\tif value == nil {\n\t\treturn \"\"\n\t}\n\tif port := value.Port(); port != \"\" {\n\t\treturn port\n\t}\n\tswitch strings.ToLower(value.Scheme) {\n\tcase \"https\":\n\t\treturn \"443\"\n\tcase \"http\":\n\t\treturn \"80\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// AutoAddRoutes 返回 provider 快速添加时需要创建的渠道 route。\nfunc (t *ProviderTemplate) AutoAddRoutes() []ProviderRoute {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tif len(t.Routes) > 0 {\n\t\tout := make([]ProviderRoute, len(t.Routes))\n\t\tcopy(out, t.Routes)\n\t\treturn out\n\t}\n\tif t.ChannelKind == \"\" || t.ServiceType == \"\" {\n\t\treturn nil\n\t}\n\treturn []ProviderRoute{{\n\t\tChannelKind: t.ChannelKind,\n\t\tServiceType: t.ServiceType,\n\t\tCandidates: append([]ProviderCandidate(nil), t.Candidates...),\n\t}}\n}\n\n// SupportsChannelKind 判断 provider 是否可从指定渠道页发起快速添加。\nfunc (t *ProviderTemplate) SupportsChannelKind(kind string) bool {\n\tfor _, route := range t.AutoAddRoutes() {\n\t\tif route.ChannelKind == kind {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// CandidatesForKey 按 API Key 前缀返回默认 route 的候选 baseURL 顺序。\n//\n// 规则(对应用户决策):\n// 1. key 前缀命中某 PlanTag → 该 plan 的候选优先(按 Priority 升序),其余候选追加在后作为回退\n// 2. 前缀不匹配任何规则(或模板无前缀规则)→ 返回全部候选(按 Priority 升序)\n//\n// 返回的候选已按探测顺序排列,调用方依次探测,命中即用。\nfunc (t *ProviderTemplate) CandidatesForKey(apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\treturn t.CandidatesForRouteKey(ProviderRoute{Candidates: t.Candidates}, apiKey)\n}\n\n// CandidatesForRouteKey 按 API Key 前缀返回指定 route 的候选 baseURL 顺序。\nfunc (t *ProviderTemplate) CandidatesForRouteKey(route ProviderRoute, apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tmatchedPlan := \"\"\n\tfor _, rule := range t.KeyPrefixRules {\n\t\tif rule.Prefix != \"\" && strings.HasPrefix(apiKey, rule.Prefix) {\n\t\t\tmatchedPlan = rule.PlanTag\n\t\t\tbreak\n\t\t}\n\t}\n\n\tsortByPriority := func(list []ProviderCandidate) []ProviderCandidate {\n\t\tsorted := make([]ProviderCandidate, len(list))\n\t\tcopy(sorted, list)\n\t\t// 稳定插入排序(候选数量极少,无需 sort 包)\n\t\tfor i := 1; i < len(sorted); i++ {\n\t\t\tfor j := i; j > 0 && sorted[j].Priority < sorted[j-1].Priority; j-- {\n\t\t\t\tsorted[j], sorted[j-1] = sorted[j-1], sorted[j]\n\t\t\t}\n\t\t}\n\t\treturn sorted\n\t}\n\n\tif matchedPlan == \"\" {\n\t\treturn sortByPriority(route.Candidates)\n\t}\n\n\tpreferred := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfallback := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfor _, cand := range route.Candidates {\n\t\tif cand.PlanTag == matchedPlan {\n\t\t\tpreferred = append(preferred, cand)\n\t\t} else {\n\t\t\tfallback = append(fallback, cand)\n\t\t}\n\t}\n\treturn append(sortByPriority(preferred), sortByPriority(fallback)...)\n}\n\nfunc mimoClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc mimoChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc volcenginePlanClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n\nfunc volcenginePlanChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n"}, "8cb1f08da90e4858885f246ddadd488390fdba08": {"t": 1786068369.7165754, "n": 25135, "h": "4b8622458f235472", "c": "package config\n\nimport (\n\t\"math\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n)\n\n// ProviderTemplate 描述一个已知 provider 的模板化添加配置。\n//\n// 设计目标:用户只需选择 provider + 输入 API Key,系统按 key 前缀选候选 baseURL,\n// 探测验证可用性后自动创建渠道,无需手填 baseURL / 选协议 / 配兼容开关。\n//\n// baseURL 按上游原生协议区分端点:Claude 请求优先走原生 Claude 入口;\n// Chat / Codex Responses 请求走 OpenAI Chat 兼容入口,由后端做协议转换。\n// ProviderTemplate 只描述来源和端点,不再承载 channel-presets 兼容开关;\n// autoManaged 渠道的模型与能力差异由后端智能调度/ModelResolver 处理。\ntype ProviderTemplate struct {\n\tProviderID string `json:\"providerId\"` // \"mimo\" / \"deepseek\" / ...\n\tAliases []string `json:\"aliases,omitempty\"`\n\tDisplayName string `json:\"displayName\"` // \"小米 MiMo\"\n\tDescription string `json:\"description\"` // key 前缀说明等\n\tChannelKind string `json:\"channelKind\"` // 默认 route 的渠道类型,兼容旧前端\n\tServiceType string `json:\"serviceType\"` // 默认 route 的服务类型,兼容旧前端\n\tOriginType string `json:\"originType\"` // official_api / official_token_plan / relay\n\tOriginTier string `json:\"originTier\"` // first / second\n\tKeyPrefixRules []KeyPrefixRule `json:\"keyPrefixRules\"` // key 前缀 → plan 判别\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 默认 route 的候选 baseURL\n\tRoutes []ProviderRoute `json:\"routes,omitempty\"`\n\t// ModelCostMultipliers 描述同一 provider 套餐内每次模型调用的相对消耗。\n\t// key 支持模型精确 ID 或通配符;值越低越省,只在质量与实测表现相同后参与选优。\n\tModelCostMultipliers map[string]float64 `json:\"modelCostMultipliers,omitempty\"`\n\t// ModelQualityPriorities 描述 provider 已确认的同质量档模型能力顺序,值越高越强。\n\t// 该映射允许不完整;解析器仅在同档候选全部命中时比较,避免未知模型被无依据降权。\n\tModelQualityPriorities map[string]int `json:\"modelQualityPriorities,omitempty\"`\n}\n\n// ProviderRoute 描述同一 provider 在某个 CCX 渠道协议下使用的原生上游入口。\ntype ProviderRoute struct {\n\tChannelKind string `json:\"channelKind\"` // \"messages\" / \"chat\" / \"responses\"\n\tServiceType string `json:\"serviceType\"` // \"claude\" / \"openai\" / \"responses\"\n\tDescription string `json:\"description,omitempty\"` // route 说明,仅展示/诊断\n\tCandidates []ProviderCandidate `json:\"candidates\"` // 该 route 的候选 baseURL\n\t// SupportedModels 限定该 route 端点实际接受的模型,语义与 UpstreamConfig.SupportedModels 一致\n\t// (支持精确匹配、prefix* / *suffix / *contains*,以及 ! 前缀排除)。\n\t//\n\t// 仅在上游同一 baseURL 下不同协议端点的模型支持范围不一致时才需要填写:\n\t// 例如 DeepSeek 的 /v1/responses 只接受 deepseek-v4-flash,而 /v1/chat/completions\n\t// 同时接受 flash 与 pro。留空表示该 route 不额外限制模型。\n\tSupportedModels []string `json:\"supportedModels,omitempty\"`\n}\n\n// KeyPrefixRule 按 API Key 前缀判别 plan 类型。\ntype KeyPrefixRule struct {\n\tPrefix string `json:\"prefix\"` // \"sk-\" / \"tp-\"\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\"\n}\n\n// ProviderCandidate 一个候选 baseURL 及其归属信息。\ntype ProviderCandidate struct {\n\tBaseURL string `json:\"baseUrl\"` // 完整 baseURL,如 https://api.xiaomimimo.com/anthropic\n\tPlanTag string `json:\"planTag\"` // \"payg\" / \"token_plan\" / \"\"\n\tRegion string `json:\"region\"` // \"cn\" / \"sgp\" / \"ams\" / \"global\" / \"\"\n\tPriority int `json:\"priority\"` // 同 plan 内探测优先级(数字越小越先探测)\n}\n\n// builtinProviderTemplates 编译期内置的已知 provider 模板。\n//\n// URL 来源(2026-07 联网核实官方文档):\n// - MiMo: https://mimo.mi.com/docs(按量 sk- / Token Plan tp-,TP 分 cn/sgp/ams 三区域集群)\n// - DeepSeek: https://api-docs.deepseek.com(Anthropic 兼容 /anthropic)\n// - Kimi: https://api.moonshot.ai/anthropic(全球)/ https://api.moonshot.cn/anthropic(中国)\n// - GLM: https://open.bigmodel.cn/api/anthropic(Claude)与 /api/paas/v4(OpenAI)\n// - 火山方舟: https://ark.cn-beijing.volces.com/api/plan(Agent Plan)与 /api/coding(Coding Plan)\n//\n// Claude route 的 baseURL 使用 Anthropic 兼容入口且不带 /v1(claude provider 会自动补 /v1/messages)。\n// Chat/Responses route 使用 OpenAI Chat 兼容入口,由 provider 自动补协议端点。\nvar builtinProviderTemplates = []ProviderTemplate{\n\t{\n\t\tProviderID: \"mimo\",\n\t\tDisplayName: \"小米 MiMo\",\n\t\tDescription: \"sk- 按量付费 / tp- Token Plan 订阅(Claude 走 /anthropic,Chat/Codex/Gemini 走 /v1)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"sk-\", PlanTag: \"payg\"},\n\t\t\t{Prefix: \"tp-\", PlanTag: \"token_plan\"},\n\t\t},\n\t\tCandidates: mimoClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages 原生 Anthropic 兼容入口\",\n\t\t\t\tCandidates: mimoClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到 OpenAI Chat Completions\",\n\t\t\t\tCandidates: mimoChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"deepseek\",\n\t\tDisplayName: \"DeepSeek\",\n\t\tDescription: \"DeepSeek 官方 API(Claude Messages、OpenAI Chat 与 Responses 三协议原生兼容;Responses 仅支持 deepseek-v4-flash)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: deepseekClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Claude Messages Anthropic 兼容入口\",\n\t\t\t\tCandidates: deepseekClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\t\tCandidates: deepseekOpenAICandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"responses\",\n\t\t\t\tDescription: \"OpenAI Responses 原生入口(仅 deepseek-v4-flash;v4-pro 预计 2026-08 初支持)\",\n\t\t\t\tCandidates: deepseekResponsesCandidates(),\n\t\t\t\t// 上游对不支持的模型返回 400 而非忽略,因此在渠道层就限定为正向白名单:\n\t\t\t\t// 未知新模型不会被误放进该端点,v4-pro 上线后在此追加即可。\n\t\t\t\tSupportedModels: []string{\"deepseek-v4-flash\"},\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"volcengine\",\n\t\tAliases: []string{\"volc-ark\"},\n\t\tDisplayName: \"火山方舟 Agent/Coding Plan\",\n\t\tDescription: \"ark- 套餐推理 Key。Agent Plan 支持 AFP 感知路由(详见 volcengine_afp_pricing.go);Coding Plan 计费规则未核验,仅使用通用定价。模型发现需为每个 Key 绑定火山云 Access Key。\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tKeyPrefixRules: []KeyPrefixRule{\n\t\t\t{Prefix: \"ark-\", PlanTag: \"personal_plan\"},\n\t\t},\n\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\tRoutes: []ProviderRoute{\n\t\t\t{\n\t\t\t\tChannelKind: \"messages\",\n\t\t\t\tServiceType: \"claude\",\n\t\t\t\tDescription: \"Agent/Coding Plan Claude Messages 入口\",\n\t\t\t\tCandidates: volcenginePlanClaudeCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"chat\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Agent/Coding Plan OpenAI Chat Completions 入口\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"responses\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Codex/Responses 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t\t{\n\t\t\t\tChannelKind: \"gemini\",\n\t\t\t\tServiceType: \"openai\",\n\t\t\t\tDescription: \"Gemini 请求转换到套餐 Chat Completions\",\n\t\t\t\tCandidates: volcenginePlanChatCandidates(),\n\t\t\t},\n\t\t},\n\t},\n\t{\n\t\tProviderID: \"kimi\",\n\t\tAliases: []string{\"kimi-code\"},\n\t\tDisplayName: \"Kimi (Moonshot)\",\n\t\tDescription: \"Moonshot Kimi 官方 API(按量与 Coding Plan,自动判别可用入口)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: kimiClaudeCandidates(),\n\t\tRoutes: standardProviderRoutes(\"claude\", kimiClaudeCandidates(), kimiOpenAICandidates()),\n\t},\n\t{\n\t\tProviderID: \"glm\",\n\t\tDisplayName: \"智谱 GLM\",\n\t\tDescription: \"智谱 GLM 官方 API(自动识别 id.secret Key;按量与 Coding Plan)\",\n\t\tChannelKind: \"messages\",\n\t\tServiceType: \"claude\",\n\t\tOriginType: \"official_api\",\n\t\tOriginTier: \"first\",\n\t\tCandidates: glmClaudeCandidates(),\n\t\tRoutes: standardProviderRoutes(\"claude\", glmClaudeCandidates(), glmOpenAICandidates()),\n\t},\n\tcompshareProviderTemplate(),\n\tnewProviderTemplate(\n\t\t\"sensenova\",\n\t\t\"SenseNova\",\n\t\t\"商汤日日新官方 API\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://token.sensenova.cn\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://token.sensenova.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"minimax\",\n\t\t\"MiniMax\",\n\t\t\"MiniMax 官方 API\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.minimaxi.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.minimax.chat/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"dashscope\",\n\t\t\"阿里云 DashScope\",\n\t\t\"阿里云百炼按量、Coding Plan 与 Token Plan\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", dashScopeClaudeCandidates(), dashScopeOpenAICandidates()),\n\t),\n\tnewProviderTemplateWithAliases(\n\t\t\"opencode-zen\",\n\t\t[]string{\"opencode-go\"},\n\t\t\"OpenCode Zen / Go\",\n\t\t\"OpenCode 官方 Zen 按量与 Go 订阅模型网关\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"openai\", openCodeCandidates(), openCodeCandidates()),\n\t),\n\tnewProviderTemplate(\n\t\t\"tencent-lkeap\",\n\t\t\"腾讯云 TokenHub\",\n\t\t\"腾讯云大模型 Token Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/v3\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"qianfan\",\n\t\t\"百度千帆 Coding Plan\",\n\t\t\"百度智能云千帆 Coding Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://qianfan.baidubce.com/anthropic/coding\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://qianfan.baidubce.com/v2/coding#\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"xfyun\",\n\t\t\"讯飞星辰\",\n\t\t\"科大讯飞星辰 MaaS Coding Plan\",\n\t\t\"official_token_plan\",\n\t\t\"first\",\n\t\txfYunRoutes(),\n\t),\n\tnewProviderTemplate(\n\t\t\"openrouter\",\n\t\t\"OpenRouter\",\n\t\t\"OpenRouter 多模型聚合平台\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://openrouter.ai/api\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://openrouter.ai/api/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"modelscope\",\n\t\t\"ModelScope 魔搭\",\n\t\t\"阿里 ModelScope 官方推理服务\",\n\t\t\"official_api\",\n\t\t\"first\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api-inference.modelscope.cn\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api-inference.modelscope.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n\tnewProviderTemplate(\n\t\t\"originrouter\",\n\t\t\"极易云 OriginRouter\",\n\t\t\"极易云多模型 API 转发平台\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.easytransnote.com/coding\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://api.easytransnote.com/coding/v1\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t),\n}\n\nvar zhipuAPIKeyPattern = regexp.MustCompile(`^[A-Za-z0-9]{20,}\\.[A-Za-z0-9]{10,}$`)\n\nfunc standardProviderRoutes(messagesServiceType string, messagesCandidates, openAICandidates []ProviderCandidate) []ProviderRoute {\n\tmessagesDescription := \"Claude Messages 原生 Anthropic 兼容入口\"\n\tif messagesServiceType == \"openai\" {\n\t\tmessagesDescription = \"Claude Messages 请求转换到 OpenAI Chat Completions\"\n\t}\n\treturn []ProviderRoute{\n\t\t{\n\t\t\tChannelKind: \"messages\",\n\t\t\tServiceType: messagesServiceType,\n\t\t\tDescription: messagesDescription,\n\t\t\tCandidates: messagesCandidates,\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"chat\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"OpenAI Chat Completions 兼容入口\",\n\t\t\tCandidates: openAICandidates,\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"responses\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"Responses 请求转换到 OpenAI Chat Completions\",\n\t\t\tCandidates: openAICandidates,\n\t\t},\n\t}\n}\n\nfunc newProviderTemplate(providerID, displayName, description, originType, originTier string, routes []ProviderRoute) ProviderTemplate {\n\treturn newProviderTemplateWithAliases(providerID, nil, displayName, description, originType, originTier, routes)\n}\n\nfunc newProviderTemplateWithAliases(providerID string, aliases []string, displayName, description, originType, originTier string, routes []ProviderRoute) ProviderTemplate {\n\ttmpl := ProviderTemplate{\n\t\tProviderID: providerID,\n\t\tAliases: aliases,\n\t\tDisplayName: displayName,\n\t\tDescription: description,\n\t\tOriginType: originType,\n\t\tOriginTier: originTier,\n\t\tRoutes: routes,\n\t}\n\tif len(routes) > 0 {\n\t\ttmpl.ChannelKind = routes[0].ChannelKind\n\t\ttmpl.ServiceType = routes[0].ServiceType\n\t\ttmpl.Candidates = routes[0].Candidates\n\t}\n\treturn tmpl\n}\n\nfunc compshareProviderTemplate() ProviderTemplate {\n\ttmpl := newProviderTemplate(\n\t\t\"compshare\",\n\t\t\"优云智算套餐\",\n\t\t\"UCloud 优云智算模型套餐与聚合服务\",\n\t\t\"relay\",\n\t\t\"second\",\n\t\tstandardProviderRoutes(\"claude\", []ProviderCandidate{\n\t\t\t{BaseURL: \"https://cp.compshare.cn\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}, []ProviderCandidate{\n\t\t\t{BaseURL: \"https://cp.compshare.cn/v1\", PlanTag: \"subscription\", Region: \"cn\", Priority: 0},\n\t\t}),\n\t)\n\t// 优云智算控制台的套餐消耗次数;glm-5.2 当前为限时倍率。\n\ttmpl.ModelCostMultipliers = map[string]float64{\n\t\t\"glm-5.2\": 2,\n\t\t\"glm-5.1\": 6,\n\t\t\"MiniMax-M2.7\": 2,\n\t\t\"kimi-k2.6\": 5,\n\t\t\"deepseek-ai/DeepSeek-V3.2\": 1,\n\t\t\"deepseek-v4-flash\": 1,\n\t}\n\ttmpl.ModelQualityPriorities = map[string]int{\n\t\t\"glm-5.1\": 2,\n\t\t\"kimi-k2.6\": 1,\n\t}\n\treturn tmpl\n}\n\nfunc deepseekClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com/anthropic\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\nfunc deepseekOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\n// deepseekResponsesCandidates DeepSeek 原生 Responses 入口。\n// 官方 base_url 为 https://api.deepseek.com,端点为标准 /v1/responses。\nfunc deepseekResponsesCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.deepseek.com/v1/responses\", PlanTag: \"\", Region: \"\", Priority: 0},\n\t}\n}\n\nfunc glmClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t}\n}\n\nfunc glmOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t// # 表示该路径已经是版本根,调用方不得再自动补 /v1。\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/paas/v4#\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://open.bigmodel.cn/api/coding/paas/v4#\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n\nfunc kimiClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.moonshot.cn/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://api.moonshot.ai/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t\t{BaseURL: \"https://api.kimi.com/coding\", PlanTag: \"coding_plan\", Region: \"global\", Priority: 2},\n\t}\n}\n\nfunc kimiOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.moonshot.cn/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://api.moonshot.ai/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t\t{BaseURL: \"https://api.kimi.com/coding/v1\", PlanTag: \"coding_plan\", Region: \"global\", Priority: 2},\n\t}\n}\n\nfunc dashScopeClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://dashscope.aliyuncs.com/apps/anthropic\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://coding.dashscope.aliyuncs.com/apps/anthropic\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 2},\n\t}\n}\n\nfunc dashScopeOpenAICandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://dashscope.aliyuncs.com/compatible-mode/v1\", PlanTag: \"payg\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://coding.dashscope.aliyuncs.com/v1\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 2},\n\t}\n}\n\nfunc openCodeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://opencode.ai/zen/go/v1\", PlanTag: \"subscription\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://opencode.ai/zen/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 1},\n\t}\n}\n\nfunc xfYunRoutes() []ProviderRoute {\n\treturn []ProviderRoute{\n\t\t{\n\t\t\tChannelKind: \"messages\",\n\t\t\tServiceType: \"claude\",\n\t\t\tDescription: \"Claude Messages Coding Plan 入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/anthropic\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"chat\",\n\t\t\tServiceType: \"openai\",\n\t\t\tDescription: \"OpenAI Chat Completions Coding Plan 入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v2\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChannelKind: \"responses\",\n\t\t\tServiceType: \"responses\",\n\t\t\tDescription: \"OpenAI Responses Coding Plan 原生入口\",\n\t\t\tCandidates: []ProviderCandidate{\n\t\t\t\t{BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v1/responses\", PlanTag: \"coding_plan\", Region: \"cn\", Priority: 0},\n\t\t\t},\n\t\t},\n\t}\n}\n\n// ListProviderTemplates 返回所有内置 provider 模板。\nfunc ListProviderTemplates() []ProviderTemplate {\n\tout := make([]ProviderTemplate, len(builtinProviderTemplates))\n\tcopy(out, builtinProviderTemplates)\n\treturn out\n}\n\n// GetProviderTemplate 按 providerId 查找模板,未找到返回 (nil, false)。\nfunc GetProviderTemplate(providerID string) (*ProviderTemplate, bool) {\n\tproviderID = strings.ToLower(strings.TrimSpace(providerID))\n\tfor i := range builtinProviderTemplates {\n\t\ttmpl := &builtinProviderTemplates[i]\n\t\tif strings.EqualFold(tmpl.ProviderID, providerID) {\n\t\t\treturn tmpl, true\n\t\t}\n\t\tfor _, alias := range tmpl.Aliases {\n\t\t\tif strings.EqualFold(alias, providerID) {\n\t\t\t\treturn tmpl, true\n\t\t\t}\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// ModelCostMultiplierForModel 返回 provider 套餐内指定模型的相对消耗倍率。\n// 精确 ID 优先,随后按最长通配符匹配;非法、负数或非有限值视为未知。\nfunc (t *ProviderTemplate) ModelCostMultiplierForModel(modelID string) (float64, bool) {\n\tif t == nil {\n\t\treturn 0, false\n\t}\n\tmultiplier, _, ok := resolvePatternValueFold(modelID, t.ModelCostMultipliers)\n\tif !ok || multiplier < 0 || math.IsNaN(multiplier) || math.IsInf(multiplier, 0) {\n\t\treturn 0, false\n\t}\n\treturn multiplier, true\n}\n\n// ModelQualityPriorityForModel 返回 provider 已确认的同档模型能力优先级。\nfunc (t *ProviderTemplate) ModelQualityPriorityForModel(modelID string) (int, bool) {\n\tif t == nil {\n\t\treturn 0, false\n\t}\n\tpriority, _, ok := resolvePatternValueFold(modelID, t.ModelQualityPriorities)\n\tif !ok || priority <= 0 {\n\t\treturn 0, false\n\t}\n\treturn priority, true\n}\n\n// InferProviderIDFromBaseURL 仅按已知模板候选端点识别 provider。\n// 使用最长路径匹配,避免把仅承载同名模型的其他中转站误判为该渠道。\nfunc InferProviderIDFromBaseURL(baseURL string) (string, bool) {\n\ttarget, err := url.Parse(strings.TrimSuffix(strings.TrimSpace(baseURL), \"#\"))\n\tif err != nil || target.Hostname() == \"\" {\n\t\treturn \"\", false\n\t}\n\ttargetPath := strings.TrimRight(target.EscapedPath(), \"/\")\n\tbestProvider := \"\"\n\tbestPathLen := -1\n\tfor _, tmpl := range builtinProviderTemplates {\n\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\tfor _, candidate := range route.Candidates {\n\t\t\t\tcandidateURL, parseErr := url.Parse(strings.TrimSpace(candidate.BaseURL))\n\t\t\t\tif parseErr != nil || !sameURLServer(target, candidateURL) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tcandidatePath := strings.TrimRight(candidateURL.EscapedPath(), \"/\")\n\t\t\t\tif candidatePath != \"\" && targetPath != candidatePath && !strings.HasPrefix(targetPath, candidatePath+\"/\") {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif len(candidatePath) > bestPathLen {\n\t\t\t\t\tbestProvider = tmpl.ProviderID\n\t\t\t\t\tbestPathLen = len(candidatePath)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn bestProvider, bestProvider != \"\"\n}\n\n// InferProviderIDFromAPIKey 仅识别具有官方唯一格式的 API Key。\n// sk- 等共享格式无法可靠区分 provider,因此不会参与推断。\nfunc InferProviderIDFromAPIKey(apiKey string) (string, bool) {\n\tif zhipuAPIKeyPattern.MatchString(strings.TrimSpace(apiKey)) {\n\t\treturn \"glm\", true\n\t}\n\treturn \"\", false\n}\n\nfunc sameURLServer(left, right *url.URL) bool {\n\tif left == nil || right == nil || !strings.EqualFold(left.Hostname(), right.Hostname()) {\n\t\treturn false\n\t}\n\treturn effectiveURLPort(left) == effectiveURLPort(right)\n}\n\nfunc effectiveURLPort(value *url.URL) string {\n\tif value == nil {\n\t\treturn \"\"\n\t}\n\tif port := value.Port(); port != \"\" {\n\t\treturn port\n\t}\n\tswitch strings.ToLower(value.Scheme) {\n\tcase \"https\":\n\t\treturn \"443\"\n\tcase \"http\":\n\t\treturn \"80\"\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// AutoAddRoutes 返回 provider 快速添加时需要创建的渠道 route。\nfunc (t *ProviderTemplate) AutoAddRoutes() []ProviderRoute {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tif len(t.Routes) > 0 {\n\t\tout := make([]ProviderRoute, len(t.Routes))\n\t\tcopy(out, t.Routes)\n\t\treturn out\n\t}\n\tif t.ChannelKind == \"\" || t.ServiceType == \"\" {\n\t\treturn nil\n\t}\n\treturn []ProviderRoute{{\n\t\tChannelKind: t.ChannelKind,\n\t\tServiceType: t.ServiceType,\n\t\tCandidates: append([]ProviderCandidate(nil), t.Candidates...),\n\t}}\n}\n\n// SupportsChannelKind 判断 provider 是否可从指定渠道页发起快速添加。\nfunc (t *ProviderTemplate) SupportsChannelKind(kind string) bool {\n\tfor _, route := range t.AutoAddRoutes() {\n\t\tif route.ChannelKind == kind {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// CandidatesForKey 按 API Key 前缀返回默认 route 的候选 baseURL 顺序。\n//\n// 规则(对应用户决策):\n// 1. key 前缀命中某 PlanTag → 该 plan 的候选优先(按 Priority 升序),其余候选追加在后作为回退\n// 2. 前缀不匹配任何规则(或模板无前缀规则)→ 返回全部候选(按 Priority 升序)\n//\n// 返回的候选已按探测顺序排列,调用方依次探测,命中即用。\nfunc (t *ProviderTemplate) CandidatesForKey(apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\treturn t.CandidatesForRouteKey(ProviderRoute{Candidates: t.Candidates}, apiKey)\n}\n\n// CandidatesForRouteKey 按 API Key 前缀返回指定 route 的候选 baseURL 顺序。\nfunc (t *ProviderTemplate) CandidatesForRouteKey(route ProviderRoute, apiKey string) []ProviderCandidate {\n\tif t == nil {\n\t\treturn nil\n\t}\n\tmatchedPlan := \"\"\n\tfor _, rule := range t.KeyPrefixRules {\n\t\tif rule.Prefix != \"\" && strings.HasPrefix(apiKey, rule.Prefix) {\n\t\t\tmatchedPlan = rule.PlanTag\n\t\t\tbreak\n\t\t}\n\t}\n\n\tsortByPriority := func(list []ProviderCandidate) []ProviderCandidate {\n\t\tsorted := make([]ProviderCandidate, len(list))\n\t\tcopy(sorted, list)\n\t\t// 稳定插入排序(候选数量极少,无需 sort 包)\n\t\tfor i := 1; i < len(sorted); i++ {\n\t\t\tfor j := i; j > 0 && sorted[j].Priority < sorted[j-1].Priority; j-- {\n\t\t\t\tsorted[j], sorted[j-1] = sorted[j-1], sorted[j]\n\t\t\t}\n\t\t}\n\t\treturn sorted\n\t}\n\n\tif matchedPlan == \"\" {\n\t\treturn sortByPriority(route.Candidates)\n\t}\n\n\tpreferred := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfallback := make([]ProviderCandidate, 0, len(route.Candidates))\n\tfor _, cand := range route.Candidates {\n\t\tif cand.PlanTag == matchedPlan {\n\t\t\tpreferred = append(preferred, cand)\n\t\t} else {\n\t\t\tfallback = append(fallback, cand)\n\t\t}\n\t}\n\treturn append(sortByPriority(preferred), sortByPriority(fallback)...)\n}\n\nfunc mimoClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/anthropic\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/anthropic\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc mimoChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://api.xiaomimimo.com/v1\", PlanTag: \"payg\", Region: \"global\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"sgp\", Priority: 1},\n\t\t{BaseURL: \"https://token-plan-ams.xiaomimimo.com/v1\", PlanTag: \"token_plan\", Region: \"ams\", Priority: 2},\n\t}\n}\n\nfunc volcenginePlanClaudeCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n\nfunc volcenginePlanChatCandidates() []ProviderCandidate {\n\treturn []ProviderCandidate{\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/plan/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 0},\n\t\t{BaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\", PlanTag: \"personal_plan\", Region: \"cn\", Priority: 1},\n\t}\n}\n"}}, "desktop/frontend/src/locales/en.json": {"ebe33699466c4c93f605d8b16e5eb0567c127bd5": {"t": 1785595239.3935783, "n": 96470, "h": "85e5e05f0a686b91", "c": "{\n \"common.gatewayLabel\": \"CCX CORE\",\n \"common.online\": \"GATEWAY ONLINE\",\n \"common.connecting\": \"CONNECTING...\",\n \"common.offline\": \"GATEWAY OFFLINE\",\n \"common.autoGenerated\": \"Auto generated\",\n \"common.refreshWebUI\": \"Refresh Web UI\",\n \"common.version\": \"Version\",\n \"common.gatewayPort\": \"Gateway port\",\n \"common.daemonPid\": \"Daemon PID\",\n \"common.autoStart\": \"Autostart\",\n \"common.autoStartOn\": \"Enabled\",\n \"common.autoStartOff\": \"Disabled\",\n \"common.serviceHealthy\": \"Service healthy\",\n \"common.serviceStarting\": \"Gateway starting\",\n \"common.serviceDisconnected\": \"Service disconnected\",\n \"common.settings\": \"Settings\",\n \"common.save\": \"Save\",\n \"common.cancel\": \"Cancel\",\n \"common.clearSearch\": \"Clear search\",\n \"common.retry\": \"Retry\",\n \"adminApi.error.serviceNotRunning\": \"Service is not running, so the request cannot be sent\",\n \"adminApi.error.networkUnavailable\": \"Service is not running or the network is unreachable. Check whether the backend has started.\",\n \"nav.status\": \"Status\",\n \"nav.statusDesc\": \"Live status and logs\",\n \"nav.agent\": \"Agent\",\n \"nav.agentDesc\": \"Local agent configuration\",\n \"nav.channels\": \"Channels\",\n \"nav.channelsDesc\": \"Protocol channel orchestration\",\n \"nav.subscriptions\": \"Subscriptions\",\n \"nav.subscriptionsDesc\": \"Account auth and subscription access\",\n \"nav.cockpit\": \"Cockpit\",\n \"nav.cockpitDesc\": \"Conversation and subagent board\",\n \"nav.env\": \"Environment\",\n \"nav.envDesc\": \"Edit gateway env file\",\n \"nav.dashboard\": \"Dashboard\",\n \"nav.dashboardDesc\": \"Native management dashboard\",\n \"sidebar.managementSection\": \"Management\",\n \"tab.statusTitle\": \"Gateway Status\",\n \"tab.agentTitle\": \"Agent Config\",\n \"tab.channelsTitle\": \"Channels Console\",\n \"tab.subscriptionsTitle\": \"Subscriptions\",\n \"tab.cockpitTitle\": \"Cockpit\",\n \"tab.envTitle\": \"Environment Settings\",\n \"tab.dashboardTitle\": \"Management Dashboard\",\n \"sidebar.language\": \"Language\",\n \"sidebar.languageEnglish\": \"English\",\n \"sidebar.languageChinese\": \"Chinese\",\n \"setup.loading\": \"Initializing CCX Console\",\n \"setup.title\": \"CCX Desktop Initial Setup\",\n \"setup.description\": \"PROXY_ACCESS_KEY is the credential AI agents use to access upstream APIs through the CCX proxy. Every caller must have this key.\",\n \"setup.regenerate\": \"Regenerate\",\n \"setup.regenerateTitle\": \"Generate a new random key\",\n \"setup.hide\": \"Hide\",\n \"setup.show\": \"Show\",\n \"setup.copied\": \"Copied\",\n \"setup.copyKey\": \"Copy key\",\n \"setup.configPath\": \"Config file path\",\n \"setup.copyPath\": \"Copy path\",\n \"setup.hint\": \"After saving, CCX will start automatically. You can adjust other settings later on the Environment page.\",\n \"setup.saving\": \"Saving and starting...\",\n \"setup.submit\": \"Finish setup and start\",\n \"webui.notRunning\": \"CCX service is not running, so the Web UI cannot be displayed.\",\n \"webui.openInBrowser\": \"Open in browser\",\n \"metrics.gatewayPort\": \"Gateway port\",\n \"metrics.uptime\": \"Uptime\",\n \"metrics.channels\": \"Channels\",\n \"metrics.version\": \"Gateway version\",\n \"actions.start\": \"Start\",\n \"actions.stop\": \"Stop\",\n \"actions.restart\": \"Restart\",\n \"actions.openWebUI\": \"Open Dashboard\",\n \"actions.openBrowser\": \"Open in browser\",\n \"actions.refreshStatus\": \"Refresh status\",\n \"details.title\": \"Service details\",\n \"details.binary\": \"Binary\",\n \"details.binaryMissing\": \"Not found\",\n \"details.dataDir\": \"Data dir\",\n \"details.dataDirMissing\": \"Not configured\",\n \"details.healthStatus\": \"Health\",\n \"details.revealDir\": \"Reveal directory\",\n \"details.openDir\": \"Open directory\",\n \"channel.headerEyebrow\": \"Channel Preset Center\",\n \"channel.title\": \"Channel Center\",\n \"channel.description\": \"Use DeepSeek, MiMo, Kimi, GLM, SenseNova, MiniMax, DashScope, iFlytek Xinghuo, and more keys for both direct Agent routing and the unified CCX channel pool. Provider presets handle advanced switches automatically.\",\n \"channel.hasKey\": \"Key saved\",\n \"channel.promo\": \"Register via promotion link to claim the exclusive perk\",\n \"channel.console\": \"Open official console\",\n \"channel.target\": \"Target\",\n \"channel.planLabel\": \"Token Plan / Base URL\",\n \"channel.apiKeyLabel\": \"API Key\",\n \"channel.baseUrlPrefix\": \"Base URL\",\n \"channel.keySavedPlaceholder\": \"Saved locally; leave empty to reuse this Provider Key\",\n \"channel.keyInputPlaceholder\": \"Enter API Key. It is stored only in local Desktop config\",\n \"channel.missingKey\": \"Enter an API Key, or save this Provider key in Agent Config first.\",\n \"channel.reuseKey\": \"Will reuse the saved local {provider} key: {key}\",\n \"channel.name\": \"Channel name\",\n \"channel.nameHint\": \"A channel with the same name will be overwritten. Use another name to create a separate channel.\",\n \"channel.presetWrites\": \"Preset will write automatically\",\n \"channel.capabilityHint\": \"Capability toggles: reasoning / vision / model list / compatibility fields are configured by Provider.\",\n \"channel.addToCCX\": \"Add to CCX\",\n \"channel.badgeDirectAgent\": \"Agent direct\",\n \"channel.badgeNativeMessages\": \"Native Messages\",\n \"copilotOAuth.description\": \"Use GitHub OAuth to authorize Copilot. After authorization, the GitHub OAuth token is added as a key for this channel. This token is only used to communicate with GitHub; CCX automatically exchanges it for a short-lived Copilot token.\",\n \"copilotOAuth.button\": \"Sign in with GitHub Copilot\",\n \"copilotOAuth.userCode\": \"User code\",\n \"copilotOAuth.openAuthorize\": \"Open GitHub authorization\",\n \"copilotOAuth.waiting\": \"Waiting for GitHub authorization...\",\n \"copilotOAuth.success\": \"Authorization succeeded. The GitHub OAuth token has been added to this channel.\",\n \"copilotOAuth.cancel\": \"Cancel authorization\",\n \"copilotOAuth.expired\": \"The authorization code has expired. Please restart authorization.\",\n \"subscription.headerEyebrow\": \"Subscription\",\n \"subscription.title\": \"Subscriptions\",\n \"subscription.description\": \"Manage account-authorized subscription services and add the authorized access as CCX channels.\",\n \"subscription.authorized\": \"Authorized\",\n \"subscription.copilotDescription\": \"Use GitHub OAuth to authorize Copilot. The same authorization can be added to any Claude, Chat, Codex, or Gemini protocol channel. This token is only used to communicate with GitHub; CCX automatically exchanges it for a short-lived Copilot token.\",\n \"subscription.checkingChannel\": \"Checking existing Copilot channel...\",\n \"subscription.verifying\": \"Verifying GitHub account...\",\n \"subscription.addingCopilotAccount\": \"Adding account to the selected channel...\",\n \"subscription.authorizeCopilot\": \"Authorize GitHub Copilot\",\n \"subscription.authorizeAndAddAccount\": \"Authorize and add another account\",\n \"subscription.addExistingAccount\": \"Add authorized account\",\n \"subscription.copilotAuthorizationSavedOnly\": \"Authorized. No {target} channel detected — click the button below to add it.\",\n \"subscription.retryAddAccount\": \"Retry adding account\",\n \"subscription.accountsTitle\": \"{target} authorized accounts ({count})\",\n \"subscription.accountCountShort\": \"{count} account(s)\",\n \"subscription.accountsEmpty\": \"No authorized accounts on the {target} channel yet.\",\n \"subscription.accountsSummary\": \"{count} authorized account(s) pooled on the {target} channel\",\n \"subscription.accountUnnamed\": \"Unnamed account\",\n \"subscription.accountRemove\": \"Remove this account\",\n \"subscription.accountRemoveConfirm\": \"Confirm remove\",\n \"subscription.verifyFailed\": \"Could not fetch this GitHub account. Please retry.\",\n \"subscription.channelResolveFailed\": \"Channel was created but could not be located. Please retry.\",\n \"subscription.targetLabel\": \"Protocol channel\",\n \"subscription.channelExists\": \"Exists\",\n \"subscription.targetClaude\": \"Claude\",\n \"subscription.targetClaudeDesc\": \"Add to Messages channels for Claude Code protocol access\",\n \"subscription.targetChat\": \"Chat\",\n \"subscription.targetChatDesc\": \"Add to OpenAI Chat Completions channels\",\n \"subscription.targetCodex\": \"Codex\",\n \"subscription.targetCodexDesc\": \"Add to Responses channels for Codex protocol access\",\n \"subscription.targetGemini\": \"Gemini\",\n \"subscription.targetGeminiDesc\": \"Add to Gemini protocol channels\",\n \"subscription.field.uid\": \"Subscription ID\",\n \"subscription.field.name\": \"Display Name\",\n \"subscription.field.notes\": \"Notes\",\n \"subscription.newApi.connect\": \"Connect new-api\",\n \"subscription.newApi.step1Title\": \"Step 1: Verify Token\",\n \"subscription.newApi.step2Title\": \"Step 2: Confirm Provisioning Details\",\n \"subscription.newApi.baseUrl\": \"new-api Base URL\",\n \"subscription.newApi.accessToken\": \"Access Token\",\n \"subscription.newApi.userId\": \"User ID (optional)\",\n \"subscription.newApi.authTokenMode\": \"Token Mode\",\n \"subscription.newApi.verify\": \"Verify\",\n \"subscription.newApi.reVerify\": \"Re-verify\",\n \"subscription.newApi.accountPreview\": \"Account Preview\",\n \"subscription.newApi.username\": \"Username\",\n \"subscription.newApi.quota\": \"Quota\",\n \"subscription.newApi.usedQuota\": \"Used Quota\",\n \"subscription.newApi.availableModels\": \"Available Models\",\n \"subscription.newApi.groups\": \"Group Ratios\",\n \"subscription.newApi.channelKind\": \"Channel Kind\",\n \"subscription.newApi.channelName\": \"Channel Name (optional)\",\n \"subscription.newApi.provisionGroup\": \"Provision Group (optional)\",\n \"subscription.newApi.provision\": \"Provision\",\n \"subscription.newApi.provisionSuccess\": \"Key auto-created and channel provisioned. The key has been written to the channel and will not be shown again.\",\n \"subscription.newApi.discoveryStarted\": \"Model discovery started.\",\n \"channel.preset.deepseek.label\": \"DeepSeek\",\n \"channel.preset.deepseek.description\": \"V4-Pro / V4-Flash dual-flagship reasoning models at breakthrough costs. Fully open-source (MIT), delivering frontier-level performance at unbeatable prices.\",\n \"channel.preset.mimo.label\": \"MiMo\",\n \"channel.preset.mimo.description\": \"Xiaomi's in-house AI platform for the Agent era. Flagship MiMo-V2.5-Pro with trillion parameters and 1M context, powering Xiaomi's smart ecosystem across devices, vehicles, and homes.\",\n \"channel.preset.compshare.label\": \"Youyun Zhisuan Plans\",\n \"channel.preset.compshare.description\": \"Youyun Zhisuan is UCloud's AI cloud platform, offering cost-effective domestic AI model Agent Plan packages by monthly subscription or pay-as-you-go, starting from 49 CNY/month. It also provides stable access to official overseas models, supports Claude Code, Codex, and API integrations, and offers enterprise-grade high concurrency, 24/7 technical support, and self-service invoicing. Users who register through the promotion link can receive a free 5 CNY platform trial credit.\",\n \"channel.preset.runapi.label\": \"RunAPI\",\n \"channel.preset.runapi.description\": \"RunAPI is an efficient and stable API platform—an alternative to OpenRouter. A single API Key gives you access to 150+ leading models, including OpenAI, Claude, Gemini, DeepSeek, Grok, and more, at prices as low as 10% of the original (up to 90% off), with exceptional stability. It's seamlessly compatible with tools like Claude Code, OpenClaw, and others. RunAPI offers an exclusive perk for CCX users: register and contact an administrator to claim ¥7 in free credit.\",\n \"channel.preset.kimi.label\": \"Kimi / Moonshot\",\n \"channel.preset.kimi.description\": \"Moonshot AI's intelligent assistant platform. Flagship Kimi K2.6 with native multimodal, renowned for ultra-long context and Agent Swarm capabilities.\",\n \"channel.preset.glm.label\": \"GLM / BigModel\",\n \"channel.preset.glm.description\": \"Zhipu AI's model-as-a-service platform from Tsinghua. Flagship GLM-5.1 rivals frontier models in coding. Hong Kong-listed, trained entirely on Huawei Ascend chips.\",\n \"channel.preset.sensenova.label\": \"SenseNova\",\n \"channel.preset.sensenova.description\": \"SenseTime's SenseNova LLM API platform with OpenAI and Anthropic compatibility. Preset uses GLM-5.2, DeepSeek V4 Flash, and SenseNova 6.7 Flash-Lite as multimodal fallback.\",\n \"channel.preset.minimax.label\": \"MiniMax\",\n \"channel.preset.minimax.description\": \"Global multimodal AI leader (HK-listed). Flagship M3 with 1M context, native vision, and Agent capabilities. Hailuo video/audio models in global top tier. Over 70% revenue from international markets.\",\n \"channel.preset.dashscope.label\": \"Alibaba Cloud DashScope\",\n \"channel.preset.dashscope.description\": \"Alibaba Cloud's one-stop LLM platform integrating Qwen and third-party models. Full-modality coverage with pay-as-you-go, Coding Plan, and Token Plan options.\",\n \"channel.preset.opencode-zen.label\": \"OpenCode Zen / Go\",\n \"channel.preset.opencode-zen.description\": \"OpenCode's curated model gateway with the Go subscription and Zen pay-as-you-go plans sharing the same coding model preset.\",\n \"channel.preset.opencode-go.label\": \"OpenCode Go\",\n \"channel.preset.opencode-go.description\": \"OpenCode's low-cost open-source coding model subscription (from $5/month), optimized for coding agent scenarios.\",\n \"channel.preset.deepseek.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.deepseek.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.deepseek.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.deepseek.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.preset.mimo.plan.anthropic.label\": \"Pay-as-you-go (Anthropic)\",\n \"channel.preset.mimo.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.mimo.plan.openai-chat.label\": \"Pay-as-you-go (OpenAI)\",\n \"channel.preset.mimo.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.preset.mimo.plan.token-cn.label\": \"Token Plan - China (OpenAI)\",\n \"channel.preset.mimo.plan.token-cn.description\": \"China Token Plan Chat / Responses shared endpoint\",\n \"channel.preset.mimo.plan.token-sgp.label\": \"Token Plan - Singapore (OpenAI)\",\n \"channel.preset.mimo.plan.token-sgp.description\": \"Singapore Token Plan Chat / Responses shared endpoint\",\n \"channel.preset.mimo.plan.token-ams.label\": \"Token Plan - Europe (OpenAI)\",\n \"channel.preset.mimo.plan.token-ams.description\": \"Europe Token Plan Chat / Responses shared endpoint\",\n \"channel.preset.mimo.plan.token-cn-anthropic.label\": \"Token Plan - China (Anthropic)\",\n \"channel.preset.mimo.plan.token-cn-anthropic.description\": \"China Token Plan Claude Messages native endpoint\",\n \"channel.preset.mimo.plan.token-sgp-anthropic.label\": \"Token Plan - Singapore (Anthropic)\",\n \"channel.preset.mimo.plan.token-sgp-anthropic.description\": \"Singapore Token Plan Claude Messages native endpoint\",\n \"channel.preset.mimo.plan.token-ams-anthropic.label\": \"Token Plan - Europe (Anthropic)\",\n \"channel.preset.mimo.plan.token-ams-anthropic.description\": \"Europe Token Plan Claude Messages native endpoint\",\n \"channel.preset.compshare.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.compshare.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.compshare.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.compshare.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.preset.runapi.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.runapi.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.runapi.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.runapi.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.preset.unity2.label\": \"Unity2.ai\",\n \"channel.preset.unity2.description\": \"Unity2.ai is a high-performance AI model API relay platform for individual developers, teams, and enterprises. It serves leading domestic enterprises long-term, handles over 30 billion tokens daily, and supports 5000 RPM-level high concurrency. One API Key works seamlessly with Claude Code, Codex, OpenAI models, IDE plugins, and Agent workflows. With enterprise-grade stable supply capability, it maintains low latency and high availability under high concurrency, continuous calling, and team bulk procurement scenarios. Register now to claim $2 balance, join the official group for an additional $10 balance, up to $12 free credits total.\",\n \"channel.preset.unity2.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.unity2.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.unity2.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.unity2.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.preset.kimi.plan.anthropic.label\": \"Pay-as-you-go (Anthropic)\",\n \"channel.preset.kimi.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.kimi.plan.openai-chat.label\": \"Pay-as-you-go (OpenAI)\",\n \"channel.preset.kimi.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.preset.kimi.plan.coding-anthropic.label\": \"Coding Plan (Anthropic)\",\n \"channel.preset.kimi.plan.coding-anthropic.description\": \"Coding Plan Claude Messages native endpoint\",\n \"channel.preset.kimi.plan.coding-openai-chat.label\": \"Coding Plan (OpenAI)\",\n \"channel.preset.kimi.plan.coding-openai-chat.description\": \"Coding Plan Chat / Responses shared endpoint\",\n \"channel.preset.glm.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.glm.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.glm.plan.coding.label\": \"Coding Plan (OpenAI)\",\n \"channel.preset.glm.plan.coding.description\": \"Coding Plan Chat / Responses shared endpoint\",\n \"channel.preset.glm.plan.openai-chat.label\": \"General (OpenAI)\",\n \"channel.preset.glm.plan.openai-chat.description\": \"General Chat / Responses endpoint\",\n \"channel.preset.sensenova.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.sensenova.plan.anthropic.description\": \"Claude Messages compatible endpoint\",\n \"channel.preset.sensenova.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.sensenova.plan.openai-chat.description\": \"Chat endpoint; Responses are converted by CCX\",\n \"channel.preset.minimax.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.minimax.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.minimax.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.minimax.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.preset.dashscope.plan.anthropic.label\": \"Pay-as-you-go (Anthropic)\",\n \"channel.preset.dashscope.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.dashscope.plan.openai-chat.label\": \"Pay-as-you-go (OpenAI)\",\n \"channel.preset.dashscope.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.preset.dashscope.plan.coding-anthropic.label\": \"Coding Plan (Anthropic)\",\n \"channel.preset.dashscope.plan.coding-anthropic.description\": \"Coding Plan Claude Messages native endpoint\",\n \"channel.preset.dashscope.plan.coding-openai-chat.label\": \"Coding Plan (OpenAI)\",\n \"channel.preset.dashscope.plan.coding-openai-chat.description\": \"Coding Plan Chat / Responses shared endpoint\",\n \"channel.preset.dashscope.plan.token-plan-anthropic.label\": \"Token Plan (Anthropic)\",\n \"channel.preset.dashscope.plan.token-plan-anthropic.description\": \"Token Plan Claude Messages native endpoint\",\n \"channel.preset.dashscope.plan.token-plan-openai-chat.label\": \"Token Plan (OpenAI)\",\n \"channel.preset.dashscope.plan.token-plan-openai-chat.description\": \"Token Plan Chat / Responses shared endpoint\",\n \"channel.preset.tencent-lkeap.label\": \"Tencent Cloud TokenHub\",\n \"channel.preset.tencent-lkeap.description\": \"Tencent Cloud's LLM service platform integrating Hunyuan and mainstream models. Token Plan subscriptions and pay-as-you-go billing, compatible with Claude Code, Codex, and other coding tools.\",\n \"channel.preset.tencent-lkeap.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.tencent-lkeap.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.tencent-lkeap.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.tencent-lkeap.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.preset.kimi-code.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.kimi-code.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.kimi-code.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.kimi-code.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.preset.volc-ark.label\": \"Volcengine Ark Coding Plan\",\n \"channel.preset.volc-ark.description\": \"ByteDance's one-stop AI platform under Volcengine. Full-chain services for Doubao series models including fine-tuning, inference, and evaluation. Multi-vendor model support with enterprise-grade reliability.\",\n \"channel.preset.volc-ark.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.volc-ark.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.volc-ark.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.volc-ark.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.preset.qianfan.label\": \"Baidu Qianfan Coding Plan\",\n \"channel.preset.qianfan.description\": \"Baidu Cloud's enterprise LLM platform with native ERNIE 5.0 multimodal model. Multi-agent collaboration and full-lifecycle tools from development to deployment.\",\n \"channel.preset.qianfan.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.qianfan.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.qianfan.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.qianfan.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.preset.originrouter.label\": \"OriginRouter\",\n \"channel.preset.originrouter.description\": \"Unified model API forwarding platform. A single key to seamlessly switch between GPT, Claude, Gemini, and other models. High-speed and stable, ideal for multi-model mixing.\",\n \"channel.preset.originrouter.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.originrouter.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.originrouter.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.originrouter.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.preset.opencode-zen.plan.go-openai-chat.label\": \"Go subscription\",\n \"channel.preset.opencode-zen.plan.go-openai-chat.description\": \"Low-cost subscription endpoint. Messages are converted by CCX to the Chat upstream; Chat / Responses use this endpoint directly\",\n \"channel.preset.opencode-zen.plan.openai-chat.label\": \"Zen pay-as-you-go\",\n \"channel.preset.opencode-zen.plan.openai-chat.description\": \"Pay-as-you-go endpoint. Messages are converted by CCX to the Chat upstream; Chat / Responses use this endpoint directly\",\n \"channel.preset.opencode-go.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.opencode-go.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.opencode-go.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.opencode-go.plan.openai-chat.description\": \"Messages are converted by CCX to the Chat upstream; Chat / Responses use this endpoint directly\",\n \"channel.preset.modelscope.label\": \"ModelScope\",\n \"channel.preset.modelscope.description\": \"Alibaba DAMO Academy's open-source model community with 10,000+ models across text, image, and speech. Full-lifecycle support for model exploration, inference, and training.\",\n \"channel.preset.modelscope.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.modelscope.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.modelscope.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.modelscope.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.preset.xfyun.label\": \"iFlytek Xinghuo\",\n \"channel.preset.xfyun.description\": \"iFlytek Xinghuo MaaS Astron Coding Plan with separate OpenAI, Anthropic, and Responses Coding Plan endpoints.\",\n \"channel.preset.xfyun.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.xfyun.plan.anthropic.description\": \"Claude Messages Coding Plan endpoint\",\n \"channel.preset.xfyun.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.xfyun.plan.openai-chat.description\": \"OpenAI Chat Coding Plan endpoint\",\n \"channel.preset.xfyun.plan.responses.label\": \"Responses-compatible\",\n \"channel.preset.xfyun.plan.responses.description\": \"OpenAI Responses Coding Plan endpoint\",\n \"channel.preset.openrouter.label\": \"OpenRouter\",\n \"channel.preset.openrouter.description\": \"The largest AI model aggregator — access hundreds of models with a single API key. OpenAI-compatible with built-in guardrails for security, governance, and cost control.\",\n \"channel.preset.openrouter.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.openrouter.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.openrouter.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.openrouter.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.target.messages.label\": \"Messages native\",\n \"channel.target.messages.description\": \"Claude Code direct, or CCX messages channel\",\n \"channel.target.responses.label\": \"Codex Responses\",\n \"channel.target.responses.description\": \"OpenAI Responses protocol, for Codex\",\n \"channel.target.chat.label\": \"Chat passthrough\",\n \"channel.target.chat.description\": \"OpenAI Chat protocol, for Chat clients\",\n \"env.title\": \"Environment config\",\n \"env.pathDetecting\": \"Detecting\",\n \"env.refresh\": \"Refresh\",\n \"env.openingEditor\": \"Opening…\",\n \"env.openWithEditor\": \"Open in {editor}\",\n \"env.openInEditor\": \"Open in editor\",\n \"env.selectEditor\": \"Choose editor…\",\n \"env.save\": \"Save\",\n \"env.saving\": \"Saving\",\n \"env.saved\": \"Saved\",\n \"env.failed\": \"Failed\",\n \"env.hide\": \"Hide\",\n \"env.show\": \"Show\",\n \"env.copied\": \"Copied\",\n \"env.copy\": \"Copy\",\n \"env.fieldRequired\": \"{field} is required\",\n \"env.fieldDisallow\": \"{field} cannot use a sample placeholder value\",\n \"env.fieldNumber\": \"{field} must be a number\",\n \"env.fieldInteger\": \"{field} must be an integer\",\n \"env.fieldMin\": \"{field} must be at least {min}\",\n \"env.fieldMax\": \"{field} must be at most {max}\",\n \"env.loadFailed\": \"Failed to load config: {error}\",\n \"env.saveSuccess\": \".env saved; changes take effect after restarting the service\",\n \"agent.statusDetecting\": \"Detecting\",\n \"agent.statusConfigured\": \"Configured\",\n \"agent.statusPortMismatch\": \"Port mismatch\",\n \"agent.statusUnconfigured\": \"Not configured\",\n \"agent.localGateway\": \"Current CCX gateway\",\n \"agent.custom\": \"Custom\",\n \"agent.currentProvider\": \"Current provider\",\n \"agent.currentUrl\": \"Current URL\",\n \"agent.targetUrl\": \"Target URL\",\n \"agent.notSet\": \"Not configured\",\n \"agent.configPath\": \"Config file\",\n \"agent.authPath\": \"Auth file\",\n \"agent.openFileInEditor\": \"Open in editor\",\n \"agent.applyConfig\": \"Apply config\",\n \"agent.restoreConfig\": \"Restore original config\",\n \"agent.migrateSessions\": \"Migrate sessions\",\n \"agent.migrateConfirmTitle\": \"Migrate Codex sessions\",\n \"agent.migrateConfirmDesc\": \"This will rewrite Codex local session metadata to the current provider. It updates history, archived sessions, and Codex state database, but never deletes sessions.\",\n \"agent.migrateTarget\": \"Target model_provider\",\n \"agent.migrateRunning\": \"Migrating sessions…\",\n \"agent.migrateConfirm\": \"Confirm migration\",\n \"agent.migrateClose\": \"Close\",\n \"agent.migrateResultSuccess\": \"Migration complete: {migrated}/{total} session files updated\",\n \"agent.migrateResultSkippedLabel\": \"Skipped files\",\n \"agent.migrateResultFailedLabel\": \"Failed files\",\n \"agent.migrateSQLiteUpdated\": \"SQLite database updated: {count} rows\",\n \"agent.migrateSQLiteSkipped\": \"SQLite database skipped\",\n \"agent.openConsole\": \"Open official console\",\n \"agent.codexPlaceholderSaved\": \"Saved locally; leave empty to reuse the key\",\n \"agent.codexPlaceholderRequired\": \"Required: enter API Key\",\n \"agent.codexPlaceholderWriteOnly\": \"Written to Codex config only\",\n \"agent.diffPreviewApply\": \"Apply config preview\",\n \"agent.diffPreviewRestore\": \"Restore config preview\",\n \"agent.diffConfirmApply\": \"Confirm apply\",\n \"agent.diffConfirmRestore\": \"Confirm restore\",\n \"agent.diffActionCreate\": \"Create\",\n \"agent.diffActionDelete\": \"Delete\",\n \"agent.diffActionModify\": \"Modify\",\n \"agent.diffComputing\": \"Computing changes…\",\n \"agent.diffNoChanges\": \"No changes\",\n \"agent.diffExpandContext\": \"Expand {count} unchanged lines\",\n \"agent.diffCollapseContext\": \"Collapse {count} lines\",\n \"agent.diffCancel\": \"Cancel\",\n \"agent.sessionMigrationWarning\": \"Switching provider mode will make existing sessions invisible in Codex. Sessions are not deleted — switch back to the previous mode to access them.\",\n \"agent.codexMode\": \"Mode\",\n \"agent.codexQuickMode\": \"Quick mode\",\n \"agent.codexPluginMode\": \"Plugin mode\",\n \"agent.codexQuickModeHint\": \"Keeps OpenAI conversation history. Uses OpenAI as provider with the configured API endpoint.\",\n \"agent.codexPluginModeHint\": \"Enables Codex plugins. Previous OpenAI sessions may not be visible — switch back to Quick mode to access them.\",\n \"agent.provider.localGateway\": \"CCX local gateway\",\n \"agent.provider.deepseekDirect\": \"DeepSeek direct\",\n \"agent.provider.mimoDirect\": \"MiMo direct\",\n \"agent.provider.compshareDirect\": \"Compshare direct\",\n \"agent.provider.runapiDirect\": \"RunAPI direct\",\n \"agent.provider.unity2Direct\": \"Unity2.ai direct\",\n \"agent.provider.kimiDirect\": \"Kimi direct\",\n \"agent.provider.glmDirect\": \"GLM direct\",\n \"agent.provider.sensenovaDirect\": \"SenseNova direct\",\n \"agent.provider.minimaxDirect\": \"MiniMax direct\",\n \"agent.provider.dashscopeDirect\": \"DashScope direct\",\n \"agent.provider.opencodeZenDirect\": \"OpenCode Zen direct\",\n \"agent.provider.opencodeGoDirect\": \"OpenCode Go direct\",\n \"agent.provider.openaiDirect\": \"OpenAI direct\",\n \"agent.provider.openrouterDirect\": \"OpenRouter direct\",\n \"agent.provider.modelscopeDirect\": \"ModelScope direct\",\n \"agent.provider.xfyunDirect\": \"iFlytek Xinghuo MAAS direct\",\n \"agent.provider.tencentLkeapDirect\": \"Tencent TokenHub direct\",\n \"agent.provider.volcArkDirect\": \"Volc Ark direct\",\n \"agent.provider.qianfanDirect\": \"Qianfan direct\",\n \"agent.hasOwnApiKey\": \"I have my own API key\",\n \"agent.promo\": \"Register via promotion link to claim the exclusive perk\",\n \"agent.planPayAsYouGo\": \"Pay-as-you-go\",\n \"agent.planChina\": \"Subscription - China\",\n \"agent.planSingapore\": \"Subscription - Singapore\",\n \"agent.planEurope\": \"Subscription - Europe\",\n \"agent.planSubscription\": \"Subscription\",\n \"agent.billingModeMiMo\": \"MiMo billing mode\",\n \"agent.billingModeDashScope\": \"DashScope billing mode\",\n \"agent.placeholderSaved\": \"Saved locally; leave empty to reuse the key\",\n \"agent.placeholderMimo\": \"Required: MiMo API Key (tp-xxx or account key)\",\n \"agent.placeholderDashScope\": \"Required: DashScope API Key (sk-xxx or sk-sp-xxx)\",\n \"agent.placeholderRequired\": \"Required: enter API Key\",\n \"agent.codexDiagnosticTitle\": \"Codex troubleshooting\",\n \"agent.codexTroubleshoot\": \"Troubleshoot\",\n \"agent.codexTroubleshooting\": \"Checking...\",\n \"agent.codexDiagnosticHealthy\": \"Current Codex config looks consistent with CCX.\",\n \"agent.codexDiagnosticMismatch\": \"config.toml and auth.json may be inconsistent.\",\n \"agent.codexDiagnosticCheck\": \"Check item\",\n \"agent.codexDiagnosticAuthMode\": \"Auth mode\",\n \"agent.codexDiagnosticConfigPath\": \"config.toml\",\n \"agent.codexDiagnosticAuthPath\": \"auth.json\",\n \"agent.codexDiagnosticSuggestedApply\": \"Suggested action: re-apply Codex -> CCX config.\",\n \"agent.codexDiagnosticSuggestedRestore\": \"If the issue persists, restore first and then apply again.\",\n \"agent.codexDiagnosticStatusTitle\": \"Codex diagnostic summary\",\n \"agent.codexDiagnosticStatusHint\": \"Open the Agent tab to inspect Codex config consistency.\",\n \"agent.codexDiagnosticCodeMissingApiKey\": \"config.toml points to local CCX, but auth.json is missing OPENAI_API_KEY.\",\n \"agent.codexDiagnosticCodeProxyKeyMismatch\": \"The proxy key stored in Codex no longer matches the current CCX proxy key.\",\n \"agent.codexDiagnosticCodeAuthModeMismatch\": \"config.toml and auth.json disagree on the required auth mode.\",\n \"agent.codexDiagnosticCodePluginMissingBearer\": \"Plugin mode is missing experimental_bearer_token.\",\n \"agent.codexDiagnosticCodeLegacyIncomplete\": \"Legacy ccx provider config is incomplete.\",\n \"agent.codexDiagnosticCodeConfigPolluted\": \"config.toml may have been polluted by another tool and no longer matches CCX expectations.\",\n \"agent.codexDiagnosticCodeAuthUnreadable\": \"auth.json cannot be parsed and may be corrupted.\",\n \"agent.codexDiagnosticCodeModeMismatch\": \"Current Codex config is {currentMode} mode, but you selected {targetMode} mode. Please re-apply the config.\",\n \"agent.responsesDiagnosticTitle\": \"Responses channel diagnostic\",\n \"agent.responsesDiagnosticHealthy\": \"Responses channels look available.\",\n \"agent.responsesDiagnosticNoChannel\": \"No Responses channel is configured in CCX.\",\n \"agent.responsesDiagnosticAllDisabled\": \"Responses channels exist, but none is currently active.\",\n \"agent.responsesDiagnosticNoApiKeys\": \"Active Responses channels are missing API keys.\",\n \"agent.responsesDiagnosticProtocolMismatch\": \"Responses channels exist, but their protocol routing does not look compatible with /v1/responses.\",\n \"agent.responsesDiagnosticCircuitOpen\": \"All active Responses channels are currently circuit-open.\",\n \"agent.responsesDiagnosticSuggestedRefresh\": \"Suggested action: refresh channel dashboard and logs.\",\n \"agent.responsesDiagnosticSuggestedCreate\": \"Suggested action: add a Responses channel for your Codex provider.\",\n \"agent.responsesDiagnosticSuggestedEnable\": \"Suggested action: enable or resume at least one Responses channel.\",\n \"agent.responsesDiagnosticSuggestedAddKey\": \"Suggested action: add an API key to an active Responses channel.\",\n \"agent.responsesDiagnosticSuggestedProtocolMismatch\": \"Suggested action: check serviceType and baseUrl so the channel resolves to a /responses endpoint.\",\n \"agent.responsesDiagnosticSuggestedResume\": \"Suggested action: inspect logs and resume the affected channels after recovery.\",\n \"agent.responsesLogsDiagnosticTitle\": \"Recent failed requests\",\n \"agent.responsesLogsDiagnosticHealthy\": \"No recent failed Responses requests were found.\",\n \"agent.responsesLogsDiagnosticAuth\": \"Recent Responses requests failed with authentication errors (401/403).\",\n \"agent.responsesLogsDiagnosticRateLimit\": \"Recent Responses requests were rate limited (429).\",\n \"agent.responsesLogsDiagnosticUpstream5xx\": \"Recent Responses requests failed with upstream 5xx errors.\",\n \"agent.responsesLogsDiagnosticTimeout\": \"Recent Responses requests look stalled or timed out.\",\n \"agent.responsesLogsDiagnosticGeneric\": \"Recent Responses requests failed. Check channel logs for details.\",\n \"agent.responsesLogsDiagnosticSuggestedRefresh\": \"Suggested action: refresh the latest channel logs.\",\n \"agent.responsesLogsDiagnosticSuggestedAuth\": \"Suggested action: verify the upstream API key, account quota, and auth settings.\",\n \"agent.responsesLogsDiagnosticSuggestedRateLimit\": \"Suggested action: reduce request rate or wait for the upstream limit window to recover.\",\n \"agent.responsesLogsDiagnosticSuggestedUpstream5xx\": \"Suggested action: switch channel, inspect upstream status, or retry later.\",\n \"agent.responsesLogsDiagnosticSuggestedTimeout\": \"Suggested action: inspect upstream latency, timeout settings, and recent logs.\",\n \"agent.responsesLogsDiagnosticSuggestedGeneric\": \"Suggested action: open the Responses channel logs and inspect the latest failures.\",\n \"agent.codexDiagnosticIntro\": \"Troubleshooting runs on demand: config consistency → Responses channel availability → recent failed requests.\",\n \"agent.codexDiagnosticLayerConfig\": \"Layer 1 · Codex config\",\n \"agent.codexDiagnosticLayerChannels\": \"Layer 2 · Responses channels\",\n \"agent.codexDiagnosticLayerLogs\": \"Layer 3 · Recent failed requests\",\n \"env.groupAccess\": \"Access control\",\n \"env.groupAccessDesc\": \"Access keys for the proxy and admin endpoints.\",\n \"env.fieldProxyAccessKey\": \"Proxy access key\",\n \"env.placeholderProxyAccessKey\": \"Enter a strong random key\",\n \"env.fieldAdminAccessKey\": \"Admin API key\",\n \"env.placeholderAdminAccessKey\": \"Leave empty to fall back to PROXY_ACCESS_KEY\",\n \"env.descAdminAccessKey\": \"Used for the Web UI and /api/* endpoints.\",\n \"env.groupServer\": \"Server config\",\n \"env.groupServerDesc\": \"Desktop injects some runtime values on start; this section still matches .env.example.\",\n \"env.fieldPort\": \"Port\",\n \"env.descPort\": \"Preferred startup port. If occupied, the next available port is used automatically.\",\n \"env.fieldBindHost\": \"Bind host\",\n \"env.descBindHost\": \"Leave empty to listen on all interfaces (:PORT / 0.0.0.0). Use 127.0.0.1 for local-only access.\",\n \"env.fieldEnableHttps\": \"Enable local HTTPS\",\n \"env.descEnableHttps\": \"For clients such as Claude Desktop that only accept HTTPS gateways. WebURL and health checks switch to https when enabled.\",\n \"env.fieldTlsAutoCert\": \"Auto-generate local cert\",\n \"env.descTlsAutoCert\": \"Generates a temporary localhost self-signed certificate when cert files are not provided. Use cert files if the client requires a trusted certificate.\",\n \"env.fieldTlsCertFile\": \"TLS certificate file\",\n \"env.placeholderTlsCertFile\": \"Example: /Users/me/.config/ccx/localhost.pem\",\n \"env.descTlsCertFile\": \"PEM certificate path. Set together with TLS_KEY_FILE.\",\n \"env.fieldTlsKeyFile\": \"TLS private key file\",\n \"env.placeholderTlsKeyFile\": \"Example: /Users/me/.config/ccx/localhost-key.pem\",\n \"env.descTlsKeyFile\": \"PEM private key path. Set together with TLS_CERT_FILE.\",\n \"env.fieldEnv\": \"Environment\",\n \"env.descEnv\": \"production is recommended.\",\n \"env.groupWebUI\": \"Web UI config\",\n \"env.groupWebUIDesc\": \"Control whether the Web UI is enabled and which language it uses by default.\",\n \"env.fieldEnableWebUI\": \"Enable Web UI\",\n \"env.descEnableWebUI\": \"Desktop mode usually forces this on.\",\n \"env.fieldAppUILanguage\": \"Default language\",\n \"env.groupLogs\": \"Logging config\",\n \"env.groupLogsDesc\": \"Control request/response logs, SSE debugging, and response model rewriting.\",\n \"env.fieldLogLevel\": \"Log level\",\n \"env.fieldEnableRequestLogs\": \"Enable request logs\",\n \"env.fieldEnableResponseLogs\": \"Enable response logs\",\n \"env.descEnableResponseLogs\": \"Response logs may expose sensitive content.\",\n \"env.fieldQuietPollingLogs\": \"Quiet polling logs\",\n \"env.fieldRawLogOutput\": \"Raw log output\",\n \"env.fieldSseDebugLevel\": \"SSE debug level\",\n \"env.fieldRewriteResponseModel\": \"Rewrite response model\",\n \"env.groupPerformance\": \"Performance config\",\n \"env.groupPerformanceDesc\": \"Request-chain timeouts and request body size limits.\",\n \"env.fieldServerReadTimeout\": \"Server read timeout (ms)\",\n \"env.fieldMaxRequestBodySize\": \"Max request body size (MB)\",\n \"env.groupCors\": \"CORS config\",\n \"env.groupCorsDesc\": \"Cross-origin access control.\",\n \"env.fieldEnableCors\": \"Enable CORS\",\n \"env.fieldCorsOrigin\": \"Allowed Origin\",\n \"env.groupCircuitBreaker\": \"Circuit-breaker metrics config\",\n \"env.groupCircuitBreakerDesc\": \"Control scheduler metric windows and failure-rate thresholds.\",\n \"env.fieldMetricsWindowSize\": \"Window size\",\n \"env.fieldMetricsFailureThreshold\": \"Failure-rate threshold\",\n \"env.runtimeCbTitle\": \"Tuning Bench\",\n \"env.runtimeCbDesc\": \"Fine-tune circuit breaker and stream control parameters. Changes take effect immediately.\",\n \"env.runtimeCbWindowSize\": \"Sliding Window Size\",\n \"env.runtimeCbWindowSizeDesc\": \"Number of recent requests for failure rate (3-100)\",\n \"env.runtimeCbFailureThreshold\": \"Failure Rate Threshold\",\n \"env.runtimeCbFailureThresholdDesc\": \"Triggers circuit break when exceeded (0.01-1.0)\",\n \"env.runtimeCbConsecutiveFailures\": \"Consecutive Failures Threshold\",\n \"env.runtimeCbConsecutiveFailuresDesc\": \"Triggers circuit break on N consecutive retryable failures (1-100)\",\n \"env.runtimeCbRequestTimeout\": \"Non-stream request timeout\",\n \"env.runtimeCbResponseHeaderTimeout\": \"Response header timeout\",\n \"env.runtimeCbStreamFirstContentTimeout\": \"First Content Timeout\",\n \"env.runtimeCbStreamFirstContentTimeoutDesc\": \"Wait for first valid content after HTTP 200 (0=disabled, 1000-300000ms)\",\n \"env.runtimeCbStreamInactivityTimeout\": \"Inactivity Timeout\",\n \"env.runtimeCbStreamInactivityTimeoutDesc\": \"Wait for subsequent activity after first content (0=disabled, 1000-180000ms)\",\n \"env.runtimeCbStreamToolCallIdleTimeout\": \"Tool Call Idle Timeout\",\n \"env.runtimeCbStreamToolCallIdleTimeoutDesc\": \"Wait for valid output while a tool call is pending (1000-180000ms)\",\n \"env.runtimeCbPresetGentle\": \"Gentle\",\n \"env.runtimeCbPresetBalanced\": \"Balanced\",\n \"env.runtimeCbPresetAggressive\": \"Aggressive\",\n \"env.runtimeCbPresetCustom\": \"Custom\",\n \"env.runtimeCbSaved\": \"Tuning Bench settings saved. Changes take effect immediately.\",\n \"env.runtimeCbSaveFailed\": \"Failed to save: {error}\",\n \"env.runtimeCbLoadFailed\": \"Failed to load: {error}\",\n \"env.runtimeCbNoBackend\": \"Backend service not running. Start the service first.\",\n \"env.runtimeCbServiceStopped\": \"Service is stopped. Start it to configure runtime parameters.\",\n \"env.groupMetricsPersistence\": \"Metrics persistence config\",\n \"env.groupMetricsPersistenceDesc\": \"Control SQLite metrics persistence and data retention.\",\n \"env.fieldMetricsPersistenceEnabled\": \"Enable metrics persistence\",\n \"env.fieldMetricsRetentionDays\": \"Metrics retention days\",\n \"logs.searchPlaceholder\": \"Search logs...\",\n \"logs.autoScroll\": \"Auto-scroll to bottom\",\n \"logs.copied\": \"Copied!\",\n \"logs.copyAll\": \"Copy all logs\",\n \"logs.clear\": \"Clear log console\",\n \"logs.noSearchResults\": \"No matching log lines found\",\n \"logs.empty\": \"No logs yet. Start the service to view output.\",\n \"diagnostic.binaryTitle\": \"Binary not found\",\n \"diagnostic.binarySuggestionBuild\": \"Confirm the CCX binary has been built: cd backend-go && make build\",\n \"diagnostic.binarySuggestionCheckDataDir\": \"Check whether ccx-go / ccx-go.exe exists in the Desktop data directory\",\n \"diagnostic.binarySuggestionDownload\": \"Build the backend first, or download a prebuilt release from the Release page\",\n \"diagnostic.portTitle\": \"Port conflict\",\n \"diagnostic.portSuggestionInstance\": \"Check whether another CCX instance is already running\",\n \"diagnostic.portSuggestionEnv\": \"Change the PORT field in .env to another port\",\n \"diagnostic.portSuggestionInspect\": \"Use lsof -i :3688 (macOS/Linux) or netstat -ano | findstr :3688 (Windows) to inspect port usage\",\n \"diagnostic.healthTitle\": \"Health check timeout\",\n \"diagnostic.healthSuggestionLogs\": \"Check the log panel for startup errors\",\n \"diagnostic.healthSuggestionEnv\": \"Check whether .env has syntax errors\",\n \"diagnostic.healthSuggestionChannels\": \"Confirm upstream channel configuration is correct. First startup may take longer.\",\n \"diagnostic.healthSuggestionRestart\": \"Try restarting the service manually\",\n \"diagnostic.permissionTitle\": \"Insufficient permissions\",\n \"diagnostic.permissionSuggestionDataDir\": \"Check whether the data directory is writable\",\n \"diagnostic.permissionSuggestionExecutable\": \"macOS/Linux: confirm the binary is executable (chmod +x)\",\n \"diagnostic.permissionSuggestionWindows\": \"Windows: try running as administrator\",\n \"diagnostic.genericTitle\": \"Startup failed\",\n \"diagnostic.genericSuggestionLogs\": \"Check the log panel below for detailed errors\",\n \"diagnostic.genericSuggestionRestart\": \"Try restarting the service\",\n \"setup.errorEmptyKey\": \"PROXY_ACCESS_KEY cannot be empty\",\n \"env.saveSuccessHint\": \".env saved; changes take effect after restarting the service\",\n \"env.openedInEditor\": \".env opened in editor\",\n \"sidebar.versionHintStore\": \"Microsoft Store version updates automatically via Store\",\n \"sidebar.versionHintTray\": \"Check for updates from the tray menu\",\n \"sidebar.versionClickCheck\": \"Click to check for updates\",\n \"sidebar.updateAvailable\": \"New {version}\",\n \"sidebar.updateAvailableHint\": \"Click to view release notes\",\n \"sidebar.updateInstallHint\": \"Click to install update\",\n \"sidebar.updateOpenReleaseHint\": \"Click to view release notes\",\n \"sidebar.theme\": \"Theme\",\n \"sidebar.themeAuto\": \"System\",\n \"sidebar.themeLight\": \"Light\",\n \"sidebar.themeDark\": \"Dark\",\n \"app.tabs.messages\": \"Channels\",\n \"app.tabs.conversations\": \"Cockpit\",\n \"console.nativeChannelsEyebrow\": \"Native channel orchestration\",\n \"console.nativeChannelsTitle\": \"WebUI-aligned protocol workspace\",\n \"console.quickAdd\": \"Quick Add\",\n \"console.quickAddEyebrow\": \"Provider Presets\",\n \"console.quickAddTitle\": \"Quick Add Channel\",\n \"console.actions.deleting\": \"Deleting...\",\n \"common.refreshing\": \"Refreshing\",\n \"common.copy\": \"Copy\",\n \"common.copied\": \"Copied\",\n \"common.delete\": \"Delete\",\n \"common.add\": \"Add\",\n \"orchestration.searchPlaceholder\": \"Search channels...\",\n \"orchestration.noMatchingChannels\": \"No matching channels\",\n \"app.actions.refresh\": \"Refresh\",\n \"app.actions.ping\": \"Test latency\",\n \"app.actions.addChannel\": \"Add channel\",\n \"orchestration.noActiveChannels\": \"No channels yet. Click the button above to add one.\",\n \"orchestration.multiChannel\": \"Multi-channel mode\",\n \"orchestration.singleChannel\": \"Single-channel mode\",\n \"orchestration.failoverSequence\": \"Failover sequence\",\n \"orchestration.standbyPool\": \"Standby pool\",\n \"orchestration.enable\": \"Enable\",\n \"channelCard.suspended\": \"Paused\",\n \"channelCard.actions\": \"Actions\",\n \"orchestration.moveToPool\": \"Move to standby pool\",\n \"orchestration.cache\": \"Cache\",\n \"orchestration.cacheWriteHigh\": \"Cache write high\",\n \"orchestration.cacheWriteHighHint\": \"The cache write ratio is high in the last 15 minutes, which may indicate suboptimal cache-hit configuration.\",\n \"chart.globalStats\": \"Usage Stats\",\n \"status.normal\": \"Normal\",\n \"status.paused\": \"Paused\",\n \"status.tripped\": \"Tripped\",\n \"status.disabled\": \"Disabled\",\n \"channelCard.apiKeyManagement\": \"API key management\",\n \"channelCard.configuredKeys\": \"Configured keys\",\n \"orchestration.edit\": \"Edit\",\n \"capability.startTest\": \"Start test\",\n \"orchestration.logs\": \"Logs\",\n \"orchestration.copyConfig\": \"Copy config\",\n \"orchestration.openWebsite\": \"Open website\",\n \"orchestration.pause\": \"Pause\",\n \"orchestration.resume\": \"Resume\",\n \"orchestration.promotion\": \"Boost priority (5 min)\",\n \"orchestration.delete\": \"Delete\",\n \"orchestration.resumeReset\": \"Resume (reset metrics)\",\n \"orchestration.moveTop\": \"Move to top\",\n \"orchestration.moveBottom\": \"Move to bottom\",\n \"orchestration.keepOne\": \"(keep at least one)\",\n \"orchestration.deleteActiveGuard\": \"Cannot delete: keep at least one active channel in the failover sequence\",\n \"channelCard.disabledKeys\": \"Blacklisted keys\",\n \"channelCard.blacklistReason.authentication_error\": \"Auth error\",\n \"channelCard.blacklistReason.permission_error\": \"Permission denied\",\n \"channelCard.blacklistReason.insufficient_balance\": \"No balance\",\n \"channelCard.blacklistReason.insufficient_quota\": \"Quota exhausted\",\n \"channelCard.blacklistReason.unavailable\": \"Unavailable\",\n \"channelCard.blacklistReason.rate_limited\": \"Rate limited\",\n \"channelCard.blacklistReason.invalid\": \"Invalid key\",\n \"channelCard.blacklistReason.unknown\": \"Unknown\",\n \"channelEditor.title.create\": \"Add channel\",\n \"channelEditor.title.edit\": \"Edit channel\",\n \"channelEditor.managed.providerLabel\": \"Channel provider\",\n \"channelEditor.managed.officialChannel\": \"Official {provider} channel\",\n \"channelEditor.managed.officialBadge\": \"Official managed\",\n \"channelEditor.managed.editSubtitle\": \"Official {provider} channel · Manage account credentials\",\n \"channelEditor.managed.providerChannel\": \"Managed {provider} channel\",\n \"channelEditor.managed.managedBadge\": \"Auto-managed\",\n \"channelEditor.managed.providerEditSubtitle\": \"Managed {provider} channel · Manage account credentials\",\n \"channelEditor.managed.customEditSubtitle\": \"Auto-managed channel · Manage name and account credentials\",\n \"channelEditor.nav.basic\": \"Basic Info\",\n \"channelEditor.basic.name.label\": \"Name\",\n \"channelEditor.basic.name.required\": \"Channel name is required\",\n \"channelEditor.basic.serviceType.label\": \"Upstream type\",\n \"channelEditor.basic.serviceType.placeholder\": \"Select upstream type\",\n \"channelEditor.basic.serviceType.required\": \"Please select an upstream type\",\n \"channelEditor.basic.description.label\": \"Description\",\n \"channelEditor.nav.connection\": \"Connection\",\n \"channelEditor.basic.baseUrl.label\": \"Base URL\",\n \"channelEditor.basic.baseUrl.required\": \"At least one Base URL is required\",\n \"channelEditor.basic.additionalUrls.label\": \"Additional URLs (one per line)\",\n \"channelEditor.transport.proxyUrl.label\": \"Proxy URL\",\n \"channelEditor.transport.routePrefix.label\": \"Route prefix\",\n \"channelEditor.transport.skipTls.label\": \"Skip TLS verification\",\n \"channelEditor.nav.auth\": \"Authentication\",\n \"channelEditor.auth.keys.label\": \"API Keys\",\n \"channelEditor.auth.disabledKeys.label\": \"Disabled keys\",\n \"channelEditor.auth.restoreKey\": \"Restore\",\n \"channelEditor.auth.copyKey\": \"Copy key\",\n \"channelEditor.auth.deleteKey\": \"Delete key\",\n \"channelEditor.auth.addKey\": \"Add key\",\n \"channelEditor.auth.historicalKeys\": \"historical keys recorded\",\n \"channelEditor.advanced.authHeader.label\": \"Auth header\",\n \"channelEditor.advanced.authHeader.hint\": \"Override how the upstream API key is sent; Auto keeps the existing compatibility logic\",\n \"channelEditor.advanced.authHeader.auto\": \"Auto\",\n \"addChannel.testCapability\": \"Test capability\",\n \"addChannel.oneClickSetup\": \"One-click setup\",\n \"addChannel.commonFilters\": \"Common filters\",\n \"addChannel.autoConvertModelNames\": \"Automatically convert model names\",\n \"addChannel.channelName\": \"Channel name\",\n \"addChannel.apiKeys\": \"API Keys\",\n \"addChannel.detectedKeys\": \"Detected {count} API key(s)\",\n \"addChannel.enterApiKey\": \"Enter at least one API key\",\n \"copilotOAuth.quickAddKeyHint\": \"No pasted API key is required. Sign in with GitHub Copilot after creating the channel.\",\n \"addChannel.newChannelPlacementLabel\": \"Append to end\",\n \"addChannel.expectedRequest\": \"Expected request\",\n \"addChannel.apiKeyLoadBalance\": \"Multiple keys can be added as failover candidates\",\n \"addChannel.checking\": \"Checking...\",\n \"addChannel.modelsCount\": \"models {statusCode} ({count})\",\n \"channelEditor.auth.apiKeyRequired\": \"At least one API Key required\",\n \"addChannel.supportedModelsLabel\": \"Supported models (optional)\",\n \"addChannel.supportedModelsPlaceholder\": \"Press Enter after each rule, for example gpt-4*, *image*, or !*image*\",\n \"addChannel.supportedModelsHint\": \"Matches the original model requested by the client before model redirect is applied. Leave empty to allow all models. Supported forms: exact, prefix*, *suffix, *contains*, and exclusion with !.\",\n \"addChannel.supportedModelsInvalidPattern\": \"Invalid rule ignored. Only exact, prefix*, *suffix, *contains*, and ! exclusion are allowed.\",\n \"addChannel.contextCapabilityTitle\": \"Context capability\",\n \"addChannel.defaultContextWindowLabel\": \"Default context window tokens\",\n \"addChannel.defaultContextWindowHint\": \"Used when the actual model is not covered by the built-in registry or JSON overrides. Empty uses the unknown-model policy.\",\n \"addChannel.defaultMaxOutputLabel\": \"Default max output tokens\",\n \"addChannel.defaultMaxOutputHint\": \"Only filters requests that explicitly set an output limit.\",\n \"addChannel.modelCapabilitiesJsonLabel\": \"Actual model capability JSON\",\n \"addChannel.modelCapabilitiesJsonHint\": \"Keys use the actual model name after model redirect and may use wildcards. Empty object means no built-in override.\",\n \"addChannel.modelCapabilitiesJsonInvalid\": \"Enter a valid JSON object. Values may only include contextWindowTokens, maxOutputTokens, thinkingMode, and reasoningEfforts.\",\n \"addChannel.allowUnknownContextLabel\": \"Allow unknown models for large context\",\n \"addChannel.allowUnknownContextHint\": \"By default, unknown-capability models are candidates only up to 200K tokens. Enable this to allow larger requests.\",\n \"addChannel.visionFallbackLabel\": \"Vision fallback model\",\n \"addChannel.visionFallbackPlaceholder\": \"Model to use when request contains images\",\n \"addChannel.visionFallbackHint\": \"When a request contains images and the target model does not support vision, this model will be used instead. Leave empty to failover to the next channel.\",\n \"addChannel.visionFallbackReasoningLabel\": \"Fallback reasoning effort\",\n \"addChannel.visionFallbackReasoningHint\": \"Reasoning effort used when image requests switch to the vision fallback model.\",\n \"channelEditor.mapping.reasoningEffort.label\": \"Reasoning effort\",\n \"channelEditor.compat.noVisionModels\": \"No vision models (one per line)\",\n \"channelEditor.compat.visionFallback.label\": \"Vision fallback model\",\n \"channelEditor.mapping.fetchModels\": \"Fetch models\",\n \"channelEditor.mapping.fetchingModels\": \"Fetching...\",\n \"channelEditor.mapping.modelFetchNeedsConfig\": \"Base URL and API Key required to fetch models\",\n \"channelEditor.compat.visionEnabled\": \"Vision enabled\",\n \"channelEditor.compat.visionDisabled\": \"Vision disabled\",\n \"channelEditor.mapping.models\": \"Models\",\n \"channelEditor.mapping.configured.label\": \"Configured\",\n \"channelEditor.mapping.supportedModels\": \"Supported models (one per line, empty=all)\",\n \"addChannel.modelHintTip\": \"Click a target model field to load supported upstream models. Detection status for each API key appears in the key list.\",\n \"addChannel.modelMappingHintChat\": \"Configure model name mapping to redirect the requested model to a target model. Example: redirect \\\"gpt-4o\\\" to \\\"gpt-5.4\\\".\",\n \"addChannel.modelMappingHintGemini\": \"Configure model name mapping to redirect the requested model to a target model. Example: redirect \\\"gemini-pro\\\" to \\\"gemini-3.5-flash\\\".\",\n \"addChannel.modelMappingHintResponses\": \"Configure model name mapping to redirect the requested model to a target model. Example: redirect \\\"opus\\\" to \\\"gpt-5.4\\\".\",\n \"addChannel.modelMappingHintVectors\": \"Configure Embeddings model name mapping to redirect the requested vector model to a target model. Example: redirect \\\"text-embedding-3-small\\\" to \\\"jina-embeddings-v2-base-zh\\\".\",\n \"addChannel.modelMappingHintMessages\": \"Configure model name mapping to redirect the requested model to a target model. Example: redirect \\\"opus\\\" to \\\"claude-sonnet-4-5-20250929\\\".\",\n \"addChannel.targetModelPlaceholderChat\": \"e.g. gpt-5.4\",\n \"addChannel.targetModelPlaceholderResponses\": \"e.g. gpt-5.4\",\n \"addChannel.targetModelPlaceholderGemini\": \"e.g. gemini-3.5-flash\",\n \"addChannel.targetModelPlaceholderVectors\": \"e.g. text-embedding-3-small\",\n \"addChannel.targetModelPlaceholderMessages\": \"e.g. claude-opus-4-6\",\n \"addChannel.sourceModelNameTooLong\": \"Custom model name is too long (max 50 characters)\",\n \"addChannel.sourceModelNoSpaces\": \"Model name cannot contain spaces\",\n \"addChannel.sourceModelInvalidChars\": \"Model name can only contain letters, numbers, hyphens, underscores, dots, slashes, colons, @, and +\",\n \"addChannel.fillBaseUrlAndApiKey\": \"Fill in Base URL and at least one API key first\",\n \"addChannel.allApiKeysModelsFailed\": \"No API key could load the model list. Check the error shown for each API key.\",\n \"channelEditor.compat.title\": \"Compatibility\",\n \"channelEditor.compat.diagnose\": \"Diagnose\",\n \"channelEditor.compat.diagnoseApplied\": \"{count} recommendation(s) applied\",\n \"channelEditor.compat.diagnoseNoChange\": \"Current config is already optimal\",\n \"channelEditor.compat.diagnoseFailed\": \"Diagnosis failed, please check channel config\",\n \"channelEditor.compat.diagnoseAppliedShort\": \"{count} applied\",\n \"channelEditor.compat.diagnoseNoChangeShort\": \"Optimal\",\n \"channelEditor.compat.diagnoseFailedShort\": \"Failed\",\n \"channelEditor.compat.reasoningStyle.label\": \"Reasoning parameter style\",\n \"channelEditor.compat.textVerbosity.label\": \"Text verbosity\",\n \"channelEditor.compat.flags\": \"Advanced flags\",\n \"channelEditor.compat.noVision.label\": \"Skip image requests\",\n \"channelEditor.compat.fastMode.label\": \"Fast mode\",\n \"channelEditor.transport.lowQuality.label\": \"Low quality channel\",\n \"channelEditor.compat.injectDummySignature.label\": \"Inject dummy signature\",\n \"channelEditor.compat.stripThoughtSignature.label\": \"Strip thought signature\",\n \"channelEditor.compat.normalizeSystem.label\": \"Normalize system role\",\n \"channelEditor.compat.normalizeUserId.label\": \"Normalize user ID\",\n \"channelEditor.compat.stripBillingHeader.label\": \"Strip billing header\",\n \"channelEditor.runtime.autoBlacklist.label\": \"Auto blacklist abnormal balance keys\",\n \"channelEditor.compat.codexCompat.label\": \"Codex tool compatibility\",\n \"channelEditor.compat.stripCodexTools.label\": \"Strip Codex client tools\",\n \"channelEditor.nav.custom\": \"Custom Parameters\",\n \"channelEditor.transport.requestTimeout.label\": \"Request timeout (s)\",\n \"channelEditor.transport.requestTimeout.invalid\": \"Timeout must be an integer from 1 to 300 seconds\",\n \"channelEditor.transport.responseHeaderTimeout.label\": \"Response header timeout (s)\",\n \"channelEditor.transport.responseHeaderTimeout.invalid\": \"Response header timeout must be an integer from 1 to 300 seconds\",\n \"channelEditor.streamTimeout.title\": \"Stream Timeout\",\n \"channelEditor.streamTimeout.firstContent.overrideLabel\": \"Customize first content timeout\",\n \"channelEditor.streamTimeout.firstContent.label\": \"First byte wait\",\n \"channelEditor.streamTimeout.inactivity.overrideLabel\": \"Customize inactivity timeout\",\n \"channelEditor.streamTimeout.inactivity.label\": \"Post-first-byte idle\",\n \"channelEditor.streamTimeout.toolCallIdle.overrideLabel\": \"Customize tool call idle timeout\",\n \"channelEditor.streamTimeout.toolCallIdle.label\": \"Tool call idle\",\n \"channelEditor.streamTimeout.overrideHint\": \"Custom value overrides the global stream timeout for this channel\",\n \"channelEditor.streamTimeout.inheritHint\": \"Disabled means this channel inherits the global stream timeout\",\n \"channelEditor.actions.cancel\": \"Cancel\",\n \"channelEditor.actions.create\": \"Create\",\n \"channelEditor.actions.save\": \"Save\",\n \"channelEditor.error.channelNotFound\": \"Channel config not found\",\n \"channelEditor.basic.website.label\": \"Website\",\n \"channelEditor.basic.baseUrl.placeholder\": \"One per line, first is primary, the rest are failover\\nhttps://api.example.com\\nhttps://backup.example.com\",\n \"channelEditor.mapping.redirect.title\": \"Model redirect\",\n \"channelEditor.nav.advanced\": \"Advanced Options\",\n \"channelEditor.nav.outline\": \"Outline\",\n \"channelEditor.runtime.title\": \"Runtime\",\n \"channelEditor.transport.title\": \"Transport\",\n \"channelEditor.rateLimit.title\": \"Rate Limit\",\n \"channelEditor.compat.fastMode.hint\": \"Codex /fast mode speeds up generation on supported models for faster coding iteration, but consumes credits or usage limits at a higher rate\",\n \"channelEditor.compat.textVerbosity.style\": \"Text Verbosity Style\",\n \"channelEditor.compat.vision.title\": \"Vision\",\n \"channelEditor.compat.selectDefault\": \"Default\",\n \"channelEditor.compat.autoBlacklistBalance.label\": \"Auto blacklist abnormal balance keys\",\n \"channelEditor.compat.autoBlacklistBalance.hint\": \"Automatically trip/disable keys with balance errors\",\n \"channelEditor.runtime.autoLearnRateLimits.label\": \"Auto learn upstream rate limiting\",\n \"channelEditor.runtime.autoLearnRateLimits.hint\": \"Parse Retry-After / x-ratelimit-* headers and adjust cooldown dynamically\",\n \"channelEditor.rateLimit.rpm.label\": \"Max requests per minute (RPM)\",\n \"channelEditor.rateLimit.rpm.placeholder\": \"No limit\",\n \"channelEditor.rateLimit.window.label\": \"Sliding window (s)\",\n \"channelEditor.rateLimit.window.placeholder\": \"60s\",\n \"channelEditor.rateLimit.maxConcurrent.label\": \"Max concurrent streams\",\n \"channelEditor.rateLimit.maxConcurrent.placeholder\": \"No limit\",\n \"channelEditor.streamTimeout.preset.gentle\": \"Gentle\",\n \"channelEditor.streamTimeout.preset.balanced\": \"Balanced\",\n \"channelEditor.streamTimeout.preset.aggressive\": \"Aggressive\",\n \"channelEditor.streamTimeout.firstContent.hint\": \"Auto disconnect if no response\",\n \"channelEditor.streamTimeout.inactivity.hint\": \"Mid-generation stall timeout\",\n \"channelEditor.streamTimeout.toolCallIdle.hint\": \"FunctionCall delay timeout\",\n \"channelEditor.streamTimeout.timeoutThreshold\": \"Timeout threshold\",\n \"channelEditor.mapping.supportedModels.label\": \"Supported models (whitelist, leave empty for no restriction)\",\n \"channelEditor.mapping.source.duplicate\": \"This source model already has a redirect\",\n \"channelEditor.basic.info.title\": \"Basic info\",\n \"channelEditor.connection.title\": \"Connection\",\n \"channelEditor.basic.multiLineFailover\": \"Multi-line for failover\",\n \"channelEditor.basic.expectedEndpoint\": \"Expected endpoint:\",\n \"channelEditor.basic.description.placeholder\": \"Optional: note about this provider...\",\n \"channelEditor.mapping.hint\": \"Intercept source aliases in requests and route to the real upstream target model\",\n \"channelEditor.compat.visionFallback.hint\": \"Models disabled via the vision toggle on redirect rows will auto-fallback to this model for image requests\",\n \"channelEditor.mapping.addRedirect\": \"Add redirect\",\n \"channelEditor.compat.generationParams\": \"Generation params\",\n \"channelEditor.compat.textVerbosity.placeholder\": \"Default\",\n \"channelEditor.mapping.modelScope\": \"Model scope\",\n \"channelEditor.compat.noVision.hint\": \"When enabled, requests containing images skip this channel and failover to the next one\",\n \"channelEditor.compat.historicalImageLimit.label\": \"Historical image turn limit\",\n \"channelEditor.compat.historicalImageLimit.hint\": \"0 means unlimited; when set to 2-10, images beyond the limit are replaced with placeholder\",\n \"channelEditor.compat.compactModel.label\": \"Compact Model\",\n \"channelEditor.compat.compactModel.hint\": \"Upstream model name for local compact (compression/summary), bypassing model mapping. Always used for non-Responses protocol channels; for Responses channels, used as fallback when native compact fails.\",\n \"channelEditor.compat.stripThoughtSignature.hint\": \"Remove the thought_signature field from functionCall\",\n \"channelEditor.compat.injectDummySignature.hint\": \"Inject a dummy signature into functionCall for third-party APIs\",\n \"channelEditor.compat.reasoningStyle.hint\": \"Choose whether to use reasoning.effort or reasoning_effort for OpenAI-style requests\",\n \"channelEditor.compat.codexCompat.hint\": \"Enable Codex CLI compatibility mode\",\n \"channelEditor.compat.normalizeSystem.hint\": \"Extract system messages from the messages array back to the top-level system field. Native first-party APIs of Chinese models (DeepSeek, Kimi, GLM, MiniMax, etc.) often accept only user/assistant roles and usually need this on; standard Anthropic endpoints or full-featured relays usually don't. Run Diagnose to auto-detect.\",\n \"channelEditor.compat.normalizeUserId.hint\": \"Flatten metadata user ID\",\n \"channelEditor.compat.stripBillingHeader.hint\": \"Remove CCH billing suffix (default on for non-official domains)\",\n \"channelEditor.transport.lowQuality.hint\": \"Mark this channel as low quality, used as last resort\",\n \"channelEditor.runtime.autoBlacklist.hint\": \"Automatically trip/disable keys with balance errors\",\n \"channelEditor.transport.skipTls.hint\": \"Disable TLS certificate verification for upstream\",\n \"channelEditor.transport.proxyUrl.hint\": \"Proxy channel for upstream requests\",\n \"channelEditor.transport.routePrefix.hint\": \"Interface route prefix\",\n \"channelEditor.transport.requestTimeout.hint\": \"Total timeout for non-streaming upstream requests; leave empty to inherit global\",\n \"channelEditor.transport.responseHeaderTimeout.hint\": \"Wait after connection for upstream HTTP headers; relax per slow-start channel only\",\n \"channelEditor.rateLimit.section.label\": \"Rate limiting\",\n \"channelEditor.rateLimit.section.hint\": \"Proactively throttle requests before they reach the upstream to avoid 429 errors.\",\n \"channelEditor.rateLimit.rpm.hint\": \"Requests per minute limit\",\n \"channelEditor.rateLimit.burst.label\": \"Burst\",\n \"channelEditor.rateLimit.burst.hint\": \"Token bucket burst capacity. Auto if empty.\",\n \"channelEditor.rateLimit.maxConcurrent.hint\": \"Full-duplex concurrent stream limit\",\n \"addChannel.duplicateKey\": \"Duplicate key\",\n \"addChannel.addNewApiKeyPlaceholder\": \"Enter the full API key\",\n \"addChannel.customHeadersLabel\": \"Custom headers (optional)\",\n \"addChannel.customHeadersHint\": \"Add or override HTTP headers sent to the upstream service.\",\n \"addChannel.streamFirstContentTimeoutLabel\": \"First content timeout\",\n \"addChannel.streamInactivityTimeoutLabel\": \"Inactivity timeout\",\n \"addChannel.streamToolCallIdleTimeoutLabel\": \"Tool call idle timeout\",\n \"addChannel.streamTimeoutStrategyLabel\": \"Streaming timeout strategy\",\n \"addChannel.streamTimeoutStrategyInherit\": \"Inherit global\",\n \"addChannel.streamTimeoutOverrideHint\": \"Custom value overrides the global stream timeout for this channel.\",\n \"addChannel.streamTimeoutInheritHint\": \"Disabled means this channel inherits the global stream timeout.\",\n \"addChannel.headerNameLabel\": \"Header name\",\n \"addChannel.headerValueLabel\": \"Header value\",\n \"addChannel.descriptionLabel\": \"Description (optional)\",\n \"addChannel.descriptionHint\": \"Optional channel description...\",\n \"addChannel.skipTlsLabel\": \"Skip TLS certificate verification\",\n \"addChannel.skipTlsHint\": \"Enable temporarily only for self-signed certificates or domain mismatch. Keep disabled in production.\",\n \"addChannel.lowQualityLabel\": \"Low-quality channel\",\n \"addChannel.lowQualityHint\": \"When enabled, token usage is force-estimated locally and the local value is used if the deviation exceeds 5%.\",\n \"addChannel.quickMode\": \"Quick Paste\",\n \"addChannel.quickHint\": \"Paste Base URL, API Key, or full config snippet to auto-detect and fill the form.\",\n \"addChannel.applyDetected\": \"Apply detected\",\n \"addChannel.serviceTypeManual\": \"Manual\",\n \"addChannel.serviceTypeDetected\": \"Auto detected\",\n \"addChannel.serviceTypeDefault\": \"Default type\",\n \"addChannel.serviceTypeRecommendedSuffix\": \" · Recommended\",\n \"addChannel.noneDetected\": \"None detected\",\n \"addChannel.fullSubtitle\": \"Configure channel, keys, models and advanced protocol options\",\n \"addChannel.editSubtitle\": \"Modify channel configuration\",\n \"cockpit.searchPlaceholder\": \"Search conversations...\",\n \"cockpit.allKinds\": \"All kinds\",\n \"cockpit.refresh\": \"Refresh\",\n \"cockpit.total\": \"{count} conversations\",\n \"cockpit.empty\": \"No active conversations. Conversations will appear on the radar after requests pass through the gateway.\",\n \"cockpit.noMatches\": \"No matching conversations\",\n \"cockpit.overrideTitle\": \"Channel override\",\n \"cockpit.overrideHint\": \"Select the channel sequence for this conversation (by priority):\",\n \"cockpit.overrideSequence\": \"Override sequence\",\n \"cockpit.overrideRequired\": \"Please select at least one channel\",\n \"cockpit.saveOverride\": \"Save override\",\n \"cockpit.copyRawUserId\": \"Copy conversation ID\",\n \"cockpit.rawUserIdCopied\": \"Conversation ID copied\",\n \"cockpit.rawUserIdCopyFailed\": \"Copy failed\",\n \"cockpit.tooltip.statusStreaming\": \"Status: streaming\",\n \"cockpit.tooltip.statusActive\": \"Status: active\",\n \"cockpit.tooltip.statusIdle\": \"Status: idle\",\n \"cockpit.tooltip.statusUnknown\": \"Status: unknown\",\n \"cockpit.tooltip.kind\": \"Request type: {kind}\",\n \"cockpit.tooltip.requests\": \"Requests: {count}\",\n \"cockpit.tooltip.duration\": \"Duration: {duration}\",\n \"cockpit.tooltip.subagents\": \"Subagents: {count}\",\n \"cockpit.tooltip.parentConversation\": \"Go to parent conversation: {id}\",\n \"cockpit.tooltip.parentThread\": \"Parent thread: {id}\",\n \"cockpit.tooltip.childConversation\": \"Go to subagent: {id}\",\n \"cockpit.tooltip.hiddenChannels\": \"Expand to show {count} more channels\",\n \"cockpit.tooltip.restoreDefault\": \"Restore default channel order\",\n \"cockpit.tooltip.moreSubagents\": \"Expand to show {count} more subagents\",\n \"cockpit.tooltip.clearSubagentOverride\": \"Clear custom subagent routing\",\n \"cockpit.tooltip.quickCurrentChannel\": \"Click to pin current channel\",\n \"cockpit.tooltip.quickNextOverride\": \"Next override target\",\n \"cockpit.tooltip.quickSetNext\": \"Pin as priority channel for this conversation\",\n \"cockpit.tooltip.quickResumeAndSetNext\": \"Resume and pin this channel\",\n \"cockpit.tooltip.quickPinned\": \"Pinned as priority channel; click again to unpin\",\n \"cockpit.tooltip.currentChannel\": \"Currently selected channel\",\n \"cockpit.tooltip.nextChannel\": \"Next override target\",\n \"cockpit.tooltip.nextChannelTripped\": \"Next target is tripped\",\n \"cockpit.tooltip.pausedChannel\": \"Channel is paused and skipped by scheduling\",\n \"cockpit.tooltip.circuitOpen\": \"Circuit breaker is open; this channel is temporarily skipped\",\n \"cockpit.tooltip.channelAlreadyFirst\": \"{name} is already first in the channel order\",\n \"cockpit.tooltip.moveChannelToTop\": \"Move {name} to the top of the channel order\",\n \"cockpit.tooltip.demoteChannel\": \"Move {name} to the end of the channel order\",\n \"cockpit.tooltip.demoteChannelDisabled\": \"Already the last channel\",\n \"cockpit.tooltip.columnCount\": \"{column}: {count} conversations\",\n \"cockpit.overrideActivePerpetual\": \"Custom channel order is active and will not expire until restored manually\",\n \"cockpit.overrideActive\": \"Custom channel order is active and will restore default scheduling in {time}\",\n \"cockpit.restoreDefault\": \"Restore default order\",\n \"cockpit.overrideDuration\": \"Auto-restore when idle\",\n \"cockpit.noChannelsForKind\": \"No channels available for this type\",\n \"cockpit.board.streaming\": \"Streaming\",\n \"cockpit.board.streamingHint\": \"Conversations currently streaming\",\n \"cockpit.board.working\": \"Working\",\n \"cockpit.board.workingHint\": \"Root conversations with active work\",\n \"cockpit.board.subagents\": \"Subagents\",\n \"cockpit.board.subagentsHint\": \"Conversations with subagents\",\n \"cockpit.board.active\": \"Active\",\n \"cockpit.board.activeHint\": \"Active conversations that are not streaming\",\n \"cockpit.board.idle\": \"Idle\",\n \"cockpit.board.idleHint\": \"Idle conversations\",\n \"cockpit.relation.parent\": \"Parent\",\n \"cockpit.relation.parentThread\": \"Parent thread {id}\",\n \"cockpit.relation.children\": \"{count} subagents\",\n \"cockpit.active\": \"Active: {count}\",\n \"cockpit.override\": \"Override: {count}\",\n \"cockpit.mainConversation\": \"Main conversation\",\n \"cockpit.recap\": \"Recap\",\n \"cockpit.detail.requests\": \"Requests\",\n \"cockpit.detail.duration\": \"Duration\",\n \"cockpit.mainRouting\": \"Main routing\",\n \"cockpit.subagentRouting\": \"Subagent routing\",\n \"cockpit.durationDefault\": \"30 min (default)\",\n \"cockpit.duration15min\": \"15 min\",\n \"cockpit.duration1hour\": \"1 hour\",\n \"cockpit.duration2hours\": \"2 hours\",\n \"cockpit.duration4hours\": \"4 hours\",\n \"cockpit.duration8hours\": \"8 hours\",\n \"cockpit.duration12hours\": \"12 hours\",\n \"cockpit.duration24hours\": \"24 hours\",\n \"cockpit.durationNever\": \"Never restore\",\n \"cockpit.subagentFollowMain\": \"Follow main\",\n \"cockpit.subagentOverride\": \"Custom channel\",\n \"cockpit.subagentClearOverride\": \"Clear\",\n \"capability.title\": \"Capability test - {channel}\",\n \"capability.cancel\": \"Cancel Test\",\n \"console.capability.running\": \"Test in progress...\",\n \"capability.noResults\": \"No capability test results yet\",\n \"console.capability.lastResults\": \"Last test results\",\n \"capability.modelsLabel\": \"Models:\",\n \"capability.duration\": \"Duration\",\n \"console.capability.protocolResults\": \"Protocol results\",\n \"console.capability.compatible\": \"Compatible protocols\",\n \"capability.loadingTitle\": \"Testing protocol compatibility...\",\n \"capability.cancelling\": \"Cancelling...\",\n \"capability.cancelled\": \"Cancelled\",\n \"capability.partial\": \"Partially available\",\n \"capability.noCompatibleProtocols\": \"No compatible protocols\",\n \"capability.modelQueued\": \"Queued\",\n \"capability.protocolRunning\": \"Running\",\n \"capability.rpmLabel\": \"Test RPM\",\n \"capability.progressSummary\": \"{done}/{total} models finished\",\n \"capability.supported\": \"Supported\",\n \"capability.unsupported\": \"Unsupported\",\n \"capability.table.protocol\": \"Protocol\",\n \"capability.table.status\": \"Status\",\n \"capability.table.successCount\": \"Success/Total\",\n \"capability.table.latency\": \"Latency\",\n \"capability.table.streaming\": \"Streaming\",\n \"capability.table.actions\": \"Actions\",\n \"capability.copyToTab\": \"Copy to this tab\",\n \"capability.currentTab\": \"Current tab\",\n \"capability.convert\": \"{protocol} convert\",\n \"capability.modelDetailsUnavailable\": \"Model details unavailable\",\n \"capability.tooltipLatency\": \"Latency\",\n \"capability.tooltipStreaming\": \"Streaming\",\n \"capability.modelStatus\": \"Status\",\n \"capability.actualModel\": \"Actual model\",\n \"capability.codexImageGeneration\": \"Codex image tools\",\n \"capability.codexImageGenerationSummary\": \"{supported}/{total} keys supported\",\n \"capability.codexImageGenerationStripped\": \"compatible via tool stripping\",\n \"capability.retryModel\": \"Retry this model\",\n \"capability.testModel\": \"Click to test\",\n \"capability.runModeReusedRunning\": \"Reused running task\",\n \"capability.runModeResumedCancelled\": \"Resumed cancelled task\",\n \"capability.runModeCacheHit\": \"Cached result\",\n \"capability.runModeReusedPreviousResults\": \"Reused previous success\",\n \"capability.lastResults\": \"Last results\",\n \"capability.models\": \"models\",\n \"capability.snapshotUpdated\": \"Updated: {time}\",\n \"channelLogs.title\": \"Channel logs\",\n \"channelLogs.status.pending\": \"Pending\",\n \"channelLogs.status.firstByte\": \"First byte\",\n \"channelLogs.status.streaming\": \"Streaming\",\n \"channelLogs.status.completed\": \"Completed\",\n \"channelLogs.status.failed\": \"Failed\",\n \"channelLogs.status.cancelled\": \"Cancelled\",\n \"channelLogs.sourceCapabilityTest\": \"Capability test\",\n \"channelLogs.copyEntry\": \"Copy this log\",\n \"channelLogs.copiedEntry\": \"Log copied\",\n \"channelLogs.reasoning.original\": \"Original\",\n \"channelLogs.reasoning.actual\": \"Sent\",\n \"channelLogs.retry\": \"Retry\",\n \"channelLogs.duration.connect\": \"Connect\",\n \"channelLogs.duration.firstByte\": \"First byte\",\n \"channelLogs.duration.total\": \"Total duration\",\n \"console.logs.model\": \"Model\",\n \"console.logs.baseUrl\": \"URL\",\n \"console.logs.key\": \"Key\",\n \"channelLogs.status.connecting\": \"Connecting\",\n \"console.logs.duration\": \"Duration\",\n \"console.logs.error\": \"Error\",\n \"console.logs.entries\": \"entries\",\n \"channelLogs.empty\": \"No logs yet\",\n \"toast.channelCopied\": \"Channel copied to {protocol} tab\",\n \"toast.copyFailed\": \"Failed to copy channel: {message}\",\n \"tooltip.circuitBreakerSettings\": \"Tuning Bench\",\n \"chart.close\": \"Close\",\n \"chart.1h\": \"1h\",\n \"chart.6h\": \"6h\",\n \"chart.24h\": \"24h\",\n \"chart.today\": \"Today\",\n \"chart.7d\": \"7d\",\n \"chart.30d\": \"30d\",\n \"chart.90d\": \"90d\",\n \"chart.180d\": \"180d\",\n \"chart.365d\": \"1 Year\",\n \"chart.thisyear\": \"This Year\",\n \"chart.traffic\": \"Traffic\",\n \"chart.tokens\": \"Tokens\",\n \"chart.cost\": \"Cost\",\n \"chart.cache\": \"Cache\",\n \"chart.cacheRw\": \"Cache R/W\",\n \"chart.cacheRead\": \"Cache Read\",\n \"chart.cacheWrite\": \"Cache Write\",\n \"chart.totalRequests\": \"Total requests\",\n \"chart.requests\": \"Requests\",\n \"chart.successRate\": \"Availability\",\n \"chart.inputTokens\": \"Input tokens\",\n \"chart.outputTokens\": \"Output tokens\",\n \"chart.input\": \"input\",\n \"chart.output\": \"output\",\n \"chart.requestUnit\": \"requests\",\n \"chart.tokenUnit\": \"tokens\",\n \"chart.issueCount\": \"issues\",\n \"chart.total\": \"Total\",\n \"chart.totalCost\": \"Total cost\",\n \"chart.collapse\": \"Collapse\",\n \"chart.expandModelStats\": \"Show model statistics\",\n \"chart.expandChart\": \"Show chart\",\n \"chart.noData\": \"No request data\",\n \"chart.noChannelMetrics\": \"No metrics yet for {channel}\",\n \"chart.noModelData\": \"No model statistics\",\n \"chart.noModelRequestsInRange\": \"No model request records in the selected time range\",\n \"chart.noKeyUsageInRange\": \"No key usage data in the selected time range\",\n \"chart.historyLoadFailed\": \"Failed to load history data\",\n \"chart.modelStatsLoadFailed\": \"Failed to load model statistics\",\n \"chart.globalStatsLoadFailed\": \"Failed to load global statistics\",\n \"chart.noRequestsInRange\": \"No request records in the selected time range\",\n \"channelEditor.title.editSubtitle\": \"Modify channel configuration\",\n \"channelEditor.nav.redirect\": \"Model Redirect\",\n \"channelEditor.basic.name.placeholder\": \"e.g. OpenAI Production\",\n \"channelEditor.basic.website.placeholder\": \"https://openai.com\",\n \"channelEditor.auth.duplicateKey\": \"Duplicate API key\",\n \"channelEditor.auth.addNewApiKey.placeholder\": \"Enter new API key\",\n \"channelEditor.mapping.source.label\": \"Source model\",\n \"channelEditor.mapping.source.placeholder\": \"gpt-4o\",\n \"channelEditor.mapping.target.label\": \"Target model\",\n \"channelEditor.mapping.target.placeholder\": \"claude-sonnet-4-6\",\n \"console.form.presets\": \"Quick presets\",\n \"console.form.existingMappings\": \"Configured mappings\",\n \"console.form.mappings\": \"items\",\n \"console.form.toggleVision\": \"Toggle vision route\",\n \"console.form.visionFallback\": \"Vision fallback\",\n \"console.form.addNewMapping\": \"Add model mapping\",\n \"console.form.createMapping\": \"Create mapping\",\n \"channelEditor.transport.proxyUrl.placeholder\": \"socks5://...\",\n \"channelEditor.transport.requestTimeout.placeholder\": \"60\",\n \"channelEditor.transport.responseHeaderTimeout.placeholder\": \"60\",\n \"channelEditor.transport.routePrefix.placeholder\": \"kimi\",\n \"channelEditor.rateLimit.window.hint\": \"Sliding window duration\",\n \"channelEditor.streamTimeout.strategy\": \"Strategy\",\n \"channelEditor.streamTimeout.strategyInherit\": \"Inherit global\",\n \"channelEditor.timeout.title\": \"Timeout strategy\",\n \"channelEditor.timeout.hint\": \"All values are in seconds; disabled controls inherit global settings\",\n \"channelEditor.toast.updated\": \"Channel updated\",\n \"channelEditor.toast.added\": \"Channel added\",\n \"console.form.existingHeaders\": \"Configured headers\",\n \"console.form.addNewHeader\": \"Add header\",\n \"console.form.headerValuePlaceholder\": \"Header value...\",\n \"console.form.headersNote\": \"Notes\",\n \"console.form.headersNoteInject\": \"Custom headers are injected into every upstream request\",\n \"console.form.headersNoteOverride\": \"Default headers can be overridden, including User-Agent and Authorization\",\n \"console.form.headersNoteTemplate\": \"Header names and values support template variables when backend substitution is available\",\n \"capability.fetchingModels\": \"Fetching model list...\",\n \"capability.selectModels\": \"Select models to test\",\n \"capability.selectAll\": \"Select all\",\n \"capability.deselectAll\": \"Deselect all\",\n \"capability.noModelsFound\": \"No models found. Add models manually below.\",\n \"capability.addModelLabel\": \"Add model\",\n \"capability.addModelPlaceholder\": \"e.g. claude-sonnet-4-6\",\n \"capability.notStarted\": \"Not started\",\n \"capability.compatibleProtocols\": \"Compatible protocols\",\n \"capability.modelDetails\": \"Model details\",\n \"capability.attemptedModels\": \"Attempted models: {count}\",\n \"capability.startedAt\": \"Started at\",\n \"capability.testedAt\": \"Finished at\",\n \"capability.success\": \"Success\",\n \"capability.failed\": \"Failed\",\n \"capability.failedTooltip\": \"Test failed\",\n \"capability.channelFallback\": \"Channel #{id}\",\n \"capability.modelRunning\": \"Running\",\n \"capability.modelSuccess\": \"Available\",\n \"capability.modelFailed\": \"Failed\",\n \"capability.modelSkipped\": \"Skipped\",\n \"capability.reasonNotRun\": \"Not executed in this round\",\n \"capability.reasonCancelled\": \"Cancelled before completion\",\n \"capability.reasonTimeout\": \"Timed out\",\n \"capability.noApiKeyError\": \"This channel has no API key configured and cannot run capability tests.\",\n \"capability.genericJobError\": \"Capability test failed: {message}\",\n \"capability.redirectTests\": \"Model Redirect Tests\",\n \"capability.redirectTestsDesc\": \"Cross-protocol model mapping verification\",\n \"addChannel.quickInputPlaceholder\": \"Paste config snippet to auto-detect Base URL and API Key (multi-line supported)\\nhttps://api.example.com\\nhttps://api-backup.example.com\\nsk-ant-api03-xxxxxxxxxxxxxxxx\\nsk-ant-api03-yyyyyyyyyyyyyyyy\",\n \"addChannel.modelListEmptyHint\": \"No model candidates yet. Fill in Base URL and API key, then focus a model input to load /models automatically.\",\n \"addChannel.modelCapabilitiesRowsHint\": \"Model redirect targets are added here automatically with built-in presets when available. You can still add passthrough or unmapped models manually.\",\n \"addChannel.modelCapabilityRedirectTargets\": \"Redirect targets\",\n \"addChannel.modelCapabilitiesConfigured\": \"Configured model capabilities\",\n \"addChannel.modelCapabilityModelLabel\": \"Actual model\",\n \"addChannel.modelCapabilityModelPlaceholder\": \"Select or enter an upstream model\",\n \"addChannel.contextTokensShort\": \"Context\",\n \"addChannel.outputTokensShort\": \"Output\",\n \"addChannel.thinkingModeLabel\": \"Thinking mode\",\n \"addChannel.reasoningEffortsLabel\": \"Reasoning efforts\",\n \"addChannel.modelCapabilityBuiltinMatched\": \"Preset: {pattern}\",\n \"addChannel.modelCapabilitiesRowsInvalid\": \"Check model capability rows. Model names are required; token and price values must be non-negative.\",\n \"addChannel.inputCacheHitPriceLabel\": \"Cache-hit input\",\n \"addChannel.inputCacheMissPriceLabel\": \"Cache-miss input\",\n \"addChannel.outputPriceLabel\": \"Output price\",\n \"addChannel.pricingTitle\": \"Pricing\",\n \"addChannel.pricingCurrencyLabel\": \"Currency\",\n \"addChannel.pricingUnitLabel\": \"Unit\",\n \"addChannel.defaultOutputTokensMeta\": \"Default output {tokens}\",\n \"addChannel.recommendedOutputTokensMeta\": \"Recommended output {tokens}\",\n \"addChannel.modelPricingUnitHint\": \"Per 1M tokens\",\n \"addChannel.embeddingCompatibilityTitle\": \"Embedding compatibility\",\n \"addChannel.embeddingCompatibilityHint\": \"Keys use the actual upstream model name after model redirect. Fallback only stays within the same embeddingSpaceId/actual model, dimensions, and normalization semantics.\",\n \"addChannel.embeddingCompatibilityRedirectTargets\": \"Redirect targets\",\n \"addChannel.embeddingCompatibilityConfigured\": \"Configured embedding spaces\",\n \"addChannel.embeddingCapabilityModelLabel\": \"Actual model\",\n \"addChannel.embeddingCapabilityModelPlaceholder\": \"Select or enter an upstream embedding model\",\n \"addChannel.embeddingSpaceIdLabel\": \"Vector space ID\",\n \"addChannel.embeddingSpaceIdPlaceholder\": \"Empty uses the actual model name\",\n \"addChannel.embeddingDimensionsLabel\": \"Default dimensions\",\n \"addChannel.embeddingSupportedDimensionsLabel\": \"Supported request dimensions\",\n \"addChannel.embeddingSupportedDimensionsPlaceholder\": \"For example 1024, 1536\",\n \"addChannel.embeddingNormalizedLabel\": \"Normalized\",\n \"addChannel.embeddingNormalizedUnknown\": \"Unspecified\",\n \"addChannel.embeddingNormalizedTrue\": \"Normalized\",\n \"addChannel.embeddingNormalizedFalse\": \"Not normalized\",\n \"addChannel.embeddingCapabilitiesRowsInvalid\": \"Check embedding compatibility rows. Actual model is required; dimensions and supported dimensions must be positive integers.\",\n \"channelDiscovery.title\": \"Discover usable config\",\n \"channelDiscovery.hint\": \"Probe models, protocols, redirects, and compatibility switches with the current Base URL and API key.\",\n \"channelDiscovery.button\": \"Discover config\",\n \"channelDiscovery.failed\": \"Config discovery failed\",\n \"channelDiscovery.missingBaseUrl\": \"Enter a Base URL first.\",\n \"channelDiscovery.missingApiKey\": \"Enter an API key first.\",\n \"channelDiscovery.missingServiceType\": \"Choose a service type first.\",\n \"channelDiscovery.recommendedKind\": \"Recommended {kind}\",\n \"channelDiscovery.modelsFound\": \"{count} models\",\n \"channelDiscovery.mapping\": \"Recommended redirects\",\n \"channelDiscovery.reasoning\": \"Recommended reasoning effort\",\n \"channelDiscovery.reasoningNote\": \"Reasoning effort is a role-based default recommendation. Discovery does not test every effort level.\",\n \"channelDiscovery.compat\": \"Compatibility switches\",\n \"channelDiscovery.capabilities\": \"Capability checks\",\n \"channelDiscovery.capabilityToolCalls\": \"Tool calls\",\n \"channelDiscovery.capabilityVision\": \"Vision\",\n \"channelDiscovery.capabilityImageGeneration\": \"Codex image tools\",\n \"channelDiscovery.capabilityThinkingPassback\": \"Thinking passback\",\n \"channelDiscovery.capabilitySupported\": \"supported\",\n \"channelDiscovery.capabilityUnsupported\": \"unsupported\",\n \"channelDiscovery.capabilityRequired\": \"required\",\n \"channelDiscovery.capabilityNotRequired\": \"not required\",\n \"channelDiscovery.apply\": \"Apply recommendation\",\n \"channelDiscovery.applied\": \"Discovery recommendation applied. Review and save.\",\n \"autopilot.quickAdd.alreadyAdded\": \"This Base URL is already configured as \\\"{name}\\\". New keys will be added to the existing channel.\",\n \"autopilot.costPreference.balanced\": \"Balanced\",\n \"autopilot.costPreference.cost_first\": \"Cost First\",\n \"autopilot.costPreference.quality_first\": \"Quality First\",\n \"autopilot.costPreferenceDesc.balanced\": \"Balance between quality and cost.\",\n \"autopilot.costPreferenceDesc.cost_first\": \"Prioritize low-cost channels, tolerating some quality decrease.\",\n \"autopilot.costPreferenceDesc.quality_first\": \"Prioritize high-quality providers, tolerating higher costs.\",\n \"autopilot.mode.active\": \"Active\",\n \"autopilot.mode.assist\": \"Assist\",\n \"autopilot.mode.auto\": \"Auto\",\n \"autopilot.mode.dry_run\": \"Dry Run\",\n \"autopilot.mode.off\": \"Off\",\n \"autopilot.mode.shadow\": \"Shadow\",\n \"autopilot.modeDesc.assist\": \"Assist mode: reorders candidate list but does not change the final selection.\",\n \"autopilot.modeDesc.auto\": \"Fully automatic mode: filters and reorders, takes over scheduling after meeting admission criteria.\",\n \"autopilot.modeDesc.off\": \"Smart routing is completely disabled. No traces are computed or recorded.\",\n \"autopilot.modeDesc.shadow\": \"Only computes and records routing decisions without affecting real scheduling (recommended).\",\n \"autopilot.modePanel.confirmMessage\": \"About to switch to {mode} mode. This mode will affect real scheduling. Do you want to continue?\",\n \"autopilot.readiness.ready\": \"Auto mode meets the rollout gate\",\n \"autopilot.readiness.collecting\": \"Collecting safety samples for auto mode\",\n \"autopilot.readiness.progress\": \"Valid samples {samples}/{requiredSamples}, observation {hours}/{requiredHours} hours\",\n \"autopilot.readiness.successRate\": \"Success\",\n \"autopilot.readiness.fallbackRate\": \"Fallback\",\n \"autopilot.readiness.failOpenRate\": \"Fail-open\",\n \"autopilot.readiness.lastRollback\": \"At {time}, consecutive SLO regression was detected and auto was downgraded to assist.\",\n \"autopilot.readiness.reason.telemetry_unavailable\": \"Routing telemetry is unavailable\",\n \"autopilot.readiness.reason.telemetry_query_failed\": \"Routing telemetry query failed\",\n \"autopilot.readiness.reason.insufficient_samples\": \"Not enough valid samples\",\n \"autopilot.readiness.reason.insufficient_observation_time\": \"Observation time is insufficient\",\n \"autopilot.readiness.reason.success_rate_below_threshold\": \"Success rate is below 95%\",\n \"autopilot.readiness.reason.fallback_rate_above_threshold\": \"Fallback rate is above 10%\",\n \"autopilot.readiness.reason.failopen_rate_above_threshold\": \"Fail-open rate is above 2%\",\n \"autopilot.readiness.reason.insufficient_comparison_samples\": \"Not enough recent and baseline samples\",\n \"autopilot.readiness.reason.recent_success_regression\": \"Recent success rate regressed\",\n \"autopilot.readiness.reason.recent_fallback_regression\": \"Recent fallback rate increased\",\n \"autopilot.readiness.reason.recent_latency_regression\": \"Recent p95 latency regressed\",\n \"autopilot.modePanel.confirmTitle\": \"Confirm Mode Switch\",\n \"autopilot.modePanel.costPreference\": \"Cost Preference\",\n \"autopilot.modePanel.killSwitch\": \"Kill Switch\",\n \"autopilot.modePanel.killSwitchActive\": \"Kill switch is active. Smart routing is forced off. Disable the kill switch first to use other modes.\",\n \"autopilot.modePanel.killSwitchHint\": \"When enabled, forces smart routing off. Takes priority over routing mode selection.\",\n \"autopilot.modePanel.reset\": \"Reset\",\n \"autopilot.modePanel.routingMode\": \"Routing Mode\",\n \"autopilot.modePanel.save\": \"Save Configuration\",\n \"autopilot.modePanel.title\": \"Global Policy\",\n \"autopilot.title\": \"Smart Routing Autopilot\",\n \"autopilot.traceStats.mismatchRate\": \"Mismatch Rate\",\n \"autopilot.traceStats.mismatches\": \"Mismatches\",\n \"autopilot.traceStats.modeDistribution\": \"Distribution by Mode\",\n \"autopilot.traceStats.taskClassDistribution\": \"Distribution by Task Class\",\n \"autopilot.traceStats.title\": \"Routing Trace Statistics\",\n \"autopilot.traceStats.total\": \"Total Traces\",\n \"autopilot.traceTable.candidates\": \"Candidates\",\n \"autopilot.traceTable.col.kind\": \"Kind\",\n \"autopilot.traceTable.col.match\": \"Match\",\n \"autopilot.traceTable.col.mode\": \"Mode\",\n \"autopilot.traceTable.col.outcome\": \"Outcome\",\n \"autopilot.traceTable.col.model\": \"Model\",\n \"autopilot.traceTable.col.shadowVsActual\": \"Shadow vs Actual\",\n \"autopilot.traceTable.col.taskClass\": \"Task Class\",\n \"autopilot.traceTable.col.time\": \"Time\",\n \"autopilot.traceTable.empty\": \"No trace records yet\",\n \"autopilot.traceTable.mismatchOnly\": \"Mismatches Only\",\n \"autopilot.traceTable.no\": \"No\",\n \"autopilot.traceTable.sortReasons\": \"Sort Reasons\",\n \"autopilot.traceTable.title\": \"Routing Decision Traces\",\n \"autopilot.traceTable.yes\": \"Yes\",\n \"healthCenter.col.channel\": \"Channel\",\n \"healthCenter.col.endpoints\": \"Endpoints\",\n \"healthCenter.col.expand\": \"Details\",\n \"healthCenter.col.kind\": \"Kind\",\n \"healthCenter.col.status\": \"Status\",\n \"healthCenter.col.successRate\": \"Success Rate\",\n \"healthCenter.detail.baseUrl\": \"Base URL\",\n \"healthCenter.detail.confidence\": \"Confidence\",\n \"healthCenter.detail.consecutiveFail\": \"Consecutive Failures\",\n \"healthCenter.detail.evidence\": \"Evidence\",\n \"healthCenter.detail.keyHash\": \"Key\",\n \"healthCenter.detail.lastSuccess\": \"Last Success\",\n \"healthCenter.detail.p95\": \"P95 Latency\",\n \"healthCenter.detail.firstByteP95\": \"P95 First Byte\",\n \"healthCenter.detail.qualityTier\": \"Quality\",\n \"healthCenter.detail.speedTier\": \"Speed\",\n \"healthCenter.detail.stabilityTier\": \"Stability\",\n \"healthCenter.detail.successRate15m\": \"Success Rate (15m)\",\n \"healthCenter.detail.successRate1h\": \"Success Rate (1h)\",\n \"healthCenter.detail.suggestedAction\": \"Suggested Action\",\n \"healthCenter.detail.updatedAt\": \"Updated At\",\n \"healthCenter.kind.chat\": \"Chat\",\n \"healthCenter.kind.gemini\": \"Gemini\",\n \"healthCenter.kind.images\": \"Images\",\n \"healthCenter.kind.messages\": \"Messages\",\n \"healthCenter.kind.responses\": \"Responses\",\n \"healthCenter.kind.vectors\": \"Vectors\",\n \"healthCenter.noChannels\": \"No channel health data yet\",\n \"healthCenter.state.dead\": \"Dead\",\n \"healthCenter.state.degraded\": \"Degraded\",\n \"healthCenter.state.healthy\": \"Healthy\",\n \"healthCenter.state.limited\": \"Limited\",\n \"healthCenter.state.misconfigured\": \"Misconfigured\",\n \"healthCenter.state.unknown\": \"Unknown\",\n \"healthCenter.title\": \"Channel Health Center\",\n \"healthCenter.totalChannels\": \"Total Channels\",\n \"healthCenter.totalEndpoints\": \"Total Endpoints\",\n \"nav.health\": \"Health Center\",\n \"nav.healthDesc\": \"Channel health overview\",\n \"nav.autopilot\": \"Autopilot\",\n \"nav.autopilotDesc\": \"Smart routing autopilot policy\",\n \"tab.healthTitle\": \"Channel Health Center\",\n \"tab.autopilotTitle\": \"Smart Routing Autopilot\",\n \"channelHealth.stateLabel\": \"Health\",\n \"channelHealth.endpointsHealthy\": \"endpoints healthy\",\n \"channelHealth.avgSuccessRate\": \"Avg success rate\",\n \"channelHealth.inconsistentWarning\": \"{count} endpoint(s) inconsistent\",\n \"healthCenter.changelog.title\": \"Recent Changes\",\n \"healthCenter.changelog.empty\": \"No changes yet\",\n \"healthCenter.changelog.live\": \"Live\",\n \"healthCenter.changelog.connecting\": \"Connecting…\",\n \"healthCenter.changelog.disconnected\": \"Live updates disconnected, reconnecting…\",\n \"healthCenter.changelog.type.healthChanged\": \"Health state changed\",\n \"healthCenter.changelog.type.profileUpdated\": \"Profile updated\",\n \"healthCenter.changelog.type.discoveryCompleted\": \"Auto-discovery completed\",\n \"healthCenter.changelog.type.autoMappingApplied\": \"Auto model mapping applied\",\n \"channelHealth.originOfficial\": \"Official API\",\n \"channelHealth.originRelay\": \"Relay\",\n \"channelHealth.originCommunity\": \"Community\",\n \"channelHealth.originUnknown\": \"Unknown origin\",\n \"channelHealth.poolFree\": \"Free pool\",\n \"channelHealth.poolTemp\": \"Temp pool\",\n \"cockpitOverview.title\": \"Operations Cockpit\",\n \"cockpitOverview.health\": \"Health Overview\",\n \"cockpitOverview.channels\": \"Channels\",\n \"cockpitOverview.endpoints\": \"Endpoints\",\n \"cockpitOverview.subscriptions\": \"Subscriptions\",\n \"cockpitOverview.totalSubscriptions\": \"Total Subscriptions\",\n \"cockpitOverview.manualIntents\": \"Manual Intents\",\n \"cockpitOverview.activeIntents\": \"Active Intents\",\n \"cockpitOverview.totalIntents\": \"Total Intents\",\n \"cockpitOverview.todoList\": \"To-Do Items\",\n \"cockpitOverview.empty\": \"No data yet. Please configure channels and subscriptions first.\",\n \"cockpitOverview.recommendations\": \"Channel Recommendations\",\n \"cockpitOverview.noRecommendations\": \"No recommendations yet. Suggestions will appear once usage data accumulates.\",\n \"cockpitOverview.usageCount\": \"{count} recent requests\",\n \"cockpitOverview.currentChannel\": \"Current channel\",\n \"cockpitOverview.recommendedChannel\": \"Try instead\",\n \"cockpitOverview.scoreDelta\": \"Score delta +{delta}\",\n \"cockpitOverview.activeTrials\": \"Active Trial Intents\",\n \"cockpitOverview.noActiveTrials\": \"No active trial intents. Create one via the Trial action in the channel center.\",\n \"cockpitOverview.trialExpired\": \"Expired\",\n \"cockpitOverview.trialStats\": \"Hits {hit} · Success {rate} · Fallback {fallback}\",\n \"cockpitOverview.trialStatsLabel\": \"Trial Stats\",\n \"cockpitOverview.remaining\": \"Remaining\",\n \"cockpitOverview.status\": \"Status\",\n \"manualIntent.field.intentType\": \"Intent type\",\n \"manualIntent.field.channelUid\": \"Channel UID (optional)\",\n \"manualIntent.field.model\": \"Model (optional)\",\n \"manualIntent.intentType.model_trial\": \"Model trial\",\n \"manualIntent.intentType.channel_trial\": \"Channel trial\",\n \"manualIntent.intentType.endpoint_trial\": \"Endpoint trial\",\n \"manualIntent.intentType.session_pin\": \"Session pin\",\n \"manualIntent.status.active\": \"Active\",\n \"manualIntent.status.expired\": \"Expired\",\n \"manualIntent.status.exhausted\": \"Exhausted\",\n \"manualIntent.status.disabled\": \"Disabled\",\n \"manualIntent.actions.end\": \"End\",\n \"manualIntent.field.channelKind\": \"Channel kind\",\n \"manualIntent.field.name\": \"Name (optional)\",\n \"manualIntent.field.metricsKey\": \"Endpoint metrics key (optional)\",\n \"manualIntent.field.mappedModel\": \"Mapped model (optional)\",\n \"manualIntent.field.sessionId\": \"Session ID\",\n \"manualIntent.field.agentRoles\": \"Agent roles\",\n \"manualIntent.field.taskClasses\": \"Task classes\",\n \"manualIntent.field.trafficPercent\": \"Traffic percent\",\n \"manualIntent.field.ttlMinutes\": \"TTL (minutes)\",\n \"manualIntent.field.maxRequests\": \"Max requests\",\n \"manualIntent.field.maxEstimatedCost\": \"Max estimated cost ($)\",\n \"manualIntent.field.fallbackOnFailure\": \"Fallback on failure\",\n \"manualIntent.field.requireHardConstraints\": \"Require hard constraints\",\n \"manualIntent.field.reason\": \"Reason\",\n \"manualIntent.agentRole.main\": \"Main agent\",\n \"manualIntent.agentRole.subagent\": \"Subagent\",\n \"manualIntent.taskClass.supervisor\": \"Supervisor\",\n \"manualIntent.taskClass.worker\": \"Worker\",\n \"manualIntent.taskClass.lightweight\": \"Lightweight\",\n \"manualIntent.taskClass.vision\": \"Vision\",\n \"manualIntent.taskClass.long_context\": \"Long context\",\n \"manualIntent.taskClass.image_generation\": \"Image generation\",\n \"manualIntent.taskClass.embedding\": \"Embedding\",\n \"manualIntent.validation.channelKindRequired\": \"Channel kind is required\",\n \"manualIntent.validation.sessionIdRequired\": \"Session pin requires a session ID\",\n \"manualIntent.actions.create\": \"Create trial\",\n \"manualIntent.actions.cancel\": \"Cancel\",\n \"manualIntent.error.createFailed\": \"Failed to create trial intent\"\n}\n"}, "4da6d77e15a5c2ac4bc15ce1ad1a68cfdbd2e2d0": {"t": 1785846933.3633628, "n": 99569, "h": "f2abefecc78959b8", "c": "{\n \"common.gatewayLabel\": \"CCX CORE\",\n \"common.online\": \"GATEWAY ONLINE\",\n \"common.connecting\": \"CONNECTING...\",\n \"common.offline\": \"GATEWAY OFFLINE\",\n \"common.autoGenerated\": \"Auto generated\",\n \"common.refreshWebUI\": \"Refresh Web UI\",\n \"common.version\": \"Version\",\n \"common.gatewayPort\": \"Gateway port\",\n \"common.daemonPid\": \"Daemon PID\",\n \"common.autoStart\": \"Autostart\",\n \"common.autoStartOn\": \"Enabled\",\n \"common.autoStartOff\": \"Disabled\",\n \"common.serviceHealthy\": \"Service healthy\",\n \"common.serviceStarting\": \"Gateway starting\",\n \"common.serviceDisconnected\": \"Service disconnected\",\n \"common.settings\": \"Settings\",\n \"common.save\": \"Save\",\n \"common.cancel\": \"Cancel\",\n \"common.clearSearch\": \"Clear search\",\n \"common.retry\": \"Retry\",\n \"adminApi.error.serviceNotRunning\": \"Service is not running, so the request cannot be sent\",\n \"adminApi.error.networkUnavailable\": \"Service is not running or the network is unreachable. Check whether the backend has started.\",\n \"nav.status\": \"Status\",\n \"nav.statusDesc\": \"Live status and logs\",\n \"nav.agent\": \"Agent\",\n \"nav.agentDesc\": \"Local agent configuration\",\n \"nav.channels\": \"Channels\",\n \"nav.channelsDesc\": \"Protocol channel orchestration\",\n \"nav.subscriptions\": \"Subscriptions\",\n \"nav.subscriptionsDesc\": \"Account auth and subscription access\",\n \"nav.cockpit\": \"Cockpit\",\n \"nav.cockpitDesc\": \"Conversation and subagent board\",\n \"nav.env\": \"Environment\",\n \"nav.envDesc\": \"Edit gateway env file\",\n \"nav.dashboard\": \"Dashboard\",\n \"nav.dashboardDesc\": \"Native management dashboard\",\n \"sidebar.managementSection\": \"Management\",\n \"tab.statusTitle\": \"Gateway Status\",\n \"tab.agentTitle\": \"Agent Config\",\n \"tab.channelsTitle\": \"Channels Console\",\n \"tab.subscriptionsTitle\": \"Subscriptions\",\n \"tab.cockpitTitle\": \"Cockpit\",\n \"tab.envTitle\": \"Environment Settings\",\n \"tab.dashboardTitle\": \"Management Dashboard\",\n \"sidebar.language\": \"Language\",\n \"sidebar.languageEnglish\": \"English\",\n \"sidebar.languageChinese\": \"Chinese\",\n \"setup.loading\": \"Initializing CCX Console\",\n \"setup.title\": \"CCX Desktop Initial Setup\",\n \"setup.description\": \"PROXY_ACCESS_KEY is the credential AI agents use to access upstream APIs through the CCX proxy. Every caller must have this key.\",\n \"setup.regenerate\": \"Regenerate\",\n \"setup.regenerateTitle\": \"Generate a new random key\",\n \"setup.hide\": \"Hide\",\n \"setup.show\": \"Show\",\n \"setup.copied\": \"Copied\",\n \"setup.copyKey\": \"Copy key\",\n \"setup.configPath\": \"Config file path\",\n \"setup.copyPath\": \"Copy path\",\n \"setup.hint\": \"After saving, CCX will start automatically. You can adjust other settings later on the Environment page.\",\n \"setup.saving\": \"Saving and starting...\",\n \"setup.submit\": \"Finish setup and start\",\n \"webui.notRunning\": \"CCX service is not running, so the Web UI cannot be displayed.\",\n \"webui.openInBrowser\": \"Open in browser\",\n \"metrics.gatewayPort\": \"Gateway port\",\n \"metrics.uptime\": \"Uptime\",\n \"metrics.channels\": \"Channels\",\n \"metrics.version\": \"Gateway version\",\n \"actions.start\": \"Start\",\n \"actions.stop\": \"Stop\",\n \"actions.restart\": \"Restart\",\n \"actions.openWebUI\": \"Open Dashboard\",\n \"actions.openBrowser\": \"Open in browser\",\n \"actions.refreshStatus\": \"Refresh status\",\n \"details.title\": \"Service details\",\n \"details.binary\": \"Binary\",\n \"details.binaryMissing\": \"Not found\",\n \"details.dataDir\": \"Data dir\",\n \"details.dataDirMissing\": \"Not configured\",\n \"details.healthStatus\": \"Health\",\n \"details.revealDir\": \"Reveal directory\",\n \"details.openDir\": \"Open directory\",\n \"channel.headerEyebrow\": \"Channel Preset Center\",\n \"channel.title\": \"Channel Center\",\n \"channel.description\": \"Use DeepSeek, MiMo, Kimi, GLM, SenseNova, MiniMax, DashScope, iFlytek Xinghuo, and more keys for both direct Agent routing and the unified CCX channel pool. Provider presets handle advanced switches automatically.\",\n \"channel.hasKey\": \"Key saved\",\n \"channel.promo\": \"Register via promotion link to claim the exclusive perk\",\n \"channel.console\": \"Open official console\",\n \"channel.target\": \"Target\",\n \"channel.planLabel\": \"Token Plan / Base URL\",\n \"channel.apiKeyLabel\": \"API Key\",\n \"channel.baseUrlPrefix\": \"Base URL\",\n \"channel.keySavedPlaceholder\": \"Saved locally; leave empty to reuse this Provider Key\",\n \"channel.keyInputPlaceholder\": \"Enter API Key. It is stored only in local Desktop config\",\n \"channel.missingKey\": \"Enter an API Key, or save this Provider key in Agent Config first.\",\n \"channel.reuseKey\": \"Will reuse the saved local {provider} key: {key}\",\n \"channel.name\": \"Channel name\",\n \"channel.nameHint\": \"A channel with the same name will be overwritten. Use another name to create a separate channel.\",\n \"channel.presetWrites\": \"Preset will write automatically\",\n \"channel.capabilityHint\": \"Capability toggles: reasoning / vision / model list / compatibility fields are configured by Provider.\",\n \"channel.addToCCX\": \"Add to CCX\",\n \"channel.badgeDirectAgent\": \"Agent direct\",\n \"channel.badgeNativeMessages\": \"Native Messages\",\n \"copilotOAuth.description\": \"Use GitHub OAuth to authorize Copilot. After authorization, the GitHub OAuth token is added as a key for this channel. This token is only used to communicate with GitHub; CCX automatically exchanges it for a short-lived Copilot token.\",\n \"copilotOAuth.button\": \"Sign in with GitHub Copilot\",\n \"copilotOAuth.userCode\": \"User code\",\n \"copilotOAuth.openAuthorize\": \"Open GitHub authorization\",\n \"copilotOAuth.waiting\": \"Waiting for GitHub authorization...\",\n \"copilotOAuth.success\": \"Authorization succeeded. The GitHub OAuth token has been added to this channel.\",\n \"copilotOAuth.cancel\": \"Cancel authorization\",\n \"copilotOAuth.expired\": \"The authorization code has expired. Please restart authorization.\",\n \"subscription.headerEyebrow\": \"Subscription\",\n \"subscription.title\": \"Subscriptions\",\n \"subscription.description\": \"Manage account-authorized subscription services and add the authorized access as CCX channels.\",\n \"subscription.authorized\": \"Authorized\",\n \"subscription.copilotDescription\": \"Use GitHub OAuth to authorize Copilot. The same authorization can be added to any Claude, Chat, Codex, or Gemini protocol channel. This token is only used to communicate with GitHub; CCX automatically exchanges it for a short-lived Copilot token.\",\n \"subscription.checkingChannel\": \"Checking existing Copilot channel...\",\n \"subscription.verifying\": \"Verifying GitHub account...\",\n \"subscription.addingCopilotAccount\": \"Adding account to the selected channel...\",\n \"subscription.authorizeCopilot\": \"Authorize GitHub Copilot\",\n \"subscription.authorizeAndAddAccount\": \"Authorize and add another account\",\n \"subscription.addExistingAccount\": \"Add authorized account\",\n \"subscription.copilotAuthorizationSavedOnly\": \"Authorized. No {target} channel detected — click the button below to add it.\",\n \"subscription.retryAddAccount\": \"Retry adding account\",\n \"subscription.accountsTitle\": \"{target} authorized accounts ({count})\",\n \"subscription.accountCountShort\": \"{count} account(s)\",\n \"subscription.accountsEmpty\": \"No authorized accounts on the {target} channel yet.\",\n \"subscription.accountsSummary\": \"{count} authorized account(s) pooled on the {target} channel\",\n \"subscription.accountUnnamed\": \"Unnamed account\",\n \"subscription.accountRemove\": \"Remove this account\",\n \"subscription.accountRemoveConfirm\": \"Confirm remove\",\n \"subscription.verifyFailed\": \"Could not fetch this GitHub account. Please retry.\",\n \"subscription.channelResolveFailed\": \"Channel was created but could not be located. Please retry.\",\n \"subscription.targetLabel\": \"Protocol channel\",\n \"subscription.channelExists\": \"Exists\",\n \"subscription.targetClaude\": \"Claude\",\n \"subscription.targetClaudeDesc\": \"Add to Messages channels for Claude Code protocol access\",\n \"subscription.targetChat\": \"Chat\",\n \"subscription.targetChatDesc\": \"Add to OpenAI Chat Completions channels\",\n \"subscription.targetCodex\": \"Codex\",\n \"subscription.targetCodexDesc\": \"Add to Responses channels for Codex protocol access\",\n \"subscription.targetGemini\": \"Gemini\",\n \"subscription.targetGeminiDesc\": \"Add to Gemini protocol channels\",\n \"subscription.field.uid\": \"Subscription ID\",\n \"subscription.field.name\": \"Display Name\",\n \"subscription.field.notes\": \"Notes\",\n \"subscription.newApi.connect\": \"Connect new-api\",\n \"subscription.newApi.step1Title\": \"Step 1: Verify Token\",\n \"subscription.newApi.step2Title\": \"Step 2: Confirm Provisioning Details\",\n \"subscription.newApi.baseUrl\": \"new-api Base URL\",\n \"subscription.newApi.accessToken\": \"Access Token\",\n \"subscription.newApi.userId\": \"User ID (optional)\",\n \"subscription.newApi.authTokenMode\": \"Token Mode\",\n \"subscription.newApi.verify\": \"Verify\",\n \"subscription.newApi.reVerify\": \"Re-verify\",\n \"subscription.newApi.accountPreview\": \"Account Preview\",\n \"subscription.newApi.username\": \"Username\",\n \"subscription.newApi.quota\": \"Quota\",\n \"subscription.newApi.usedQuota\": \"Used Quota\",\n \"subscription.newApi.availableModels\": \"Available Models\",\n \"subscription.newApi.groups\": \"Group Ratios\",\n \"subscription.newApi.channelKind\": \"Channel Kind\",\n \"subscription.newApi.channelName\": \"Channel Name (optional)\",\n \"subscription.newApi.provisionGroup\": \"Provision Group (optional)\",\n \"subscription.newApi.provision\": \"Provision\",\n \"subscription.newApi.provisionSuccess\": \"Key auto-created and channel provisioned. The key has been written to the channel and will not be shown again.\",\n \"subscription.newApi.discoveryStarted\": \"Model discovery started.\",\n \"channel.preset.deepseek.label\": \"DeepSeek\",\n \"channel.preset.deepseek.description\": \"V4-Pro / V4-Flash dual-flagship reasoning models at breakthrough costs. Fully open-source (MIT), delivering frontier-level performance at unbeatable prices.\",\n \"channel.preset.mimo.label\": \"MiMo\",\n \"channel.preset.mimo.description\": \"Xiaomi's in-house AI platform for the Agent era. Flagship MiMo-V2.5-Pro with trillion parameters and 1M context, powering Xiaomi's smart ecosystem across devices, vehicles, and homes.\",\n \"channel.preset.compshare.label\": \"Youyun Zhisuan Plans\",\n \"channel.preset.compshare.description\": \"Youyun Zhisuan is UCloud's AI cloud platform, offering cost-effective domestic AI model Agent Plan packages by monthly subscription or pay-as-you-go, starting from 49 CNY/month. It also provides stable access to official overseas models, supports Claude Code, Codex, and API integrations, and offers enterprise-grade high concurrency, 24/7 technical support, and self-service invoicing. Users who register through the promotion link can receive a free 5 CNY platform trial credit.\",\n \"channel.preset.runapi.label\": \"RunAPI\",\n \"channel.preset.runapi.description\": \"RunAPI is an efficient and stable API platform—an alternative to OpenRouter. A single API Key gives you access to 150+ leading models, including OpenAI, Claude, Gemini, DeepSeek, Grok, and more, at prices as low as 10% of the original (up to 90% off), with exceptional stability. It's seamlessly compatible with tools like Claude Code, OpenClaw, and others. RunAPI offers an exclusive perk for CCX users: register and contact an administrator to claim ¥7 in free credit.\",\n \"channel.preset.kimi.label\": \"Kimi / Moonshot\",\n \"channel.preset.kimi.description\": \"Moonshot AI's intelligent assistant platform. Flagship Kimi K2.6 with native multimodal, renowned for ultra-long context and Agent Swarm capabilities.\",\n \"channel.preset.glm.label\": \"GLM / BigModel\",\n \"channel.preset.glm.description\": \"Zhipu AI's model-as-a-service platform from Tsinghua. Flagship GLM-5.1 rivals frontier models in coding. Hong Kong-listed, trained entirely on Huawei Ascend chips.\",\n \"channel.preset.sensenova.label\": \"SenseNova\",\n \"channel.preset.sensenova.description\": \"SenseTime's SenseNova LLM API platform with OpenAI and Anthropic compatibility. Preset uses GLM-5.2, DeepSeek V4 Flash, and SenseNova 6.7 Flash-Lite as multimodal fallback.\",\n \"channel.preset.minimax.label\": \"MiniMax\",\n \"channel.preset.minimax.description\": \"Global multimodal AI leader (HK-listed). Flagship M3 with 1M context, native vision, and Agent capabilities. Hailuo video/audio models in global top tier. Over 70% revenue from international markets.\",\n \"channel.preset.dashscope.label\": \"Alibaba Cloud DashScope\",\n \"channel.preset.dashscope.description\": \"Alibaba Cloud's one-stop LLM platform integrating Qwen and third-party models. Full-modality coverage with pay-as-you-go, Coding Plan, and Token Plan options.\",\n \"channel.preset.opencode-zen.label\": \"OpenCode Zen / Go\",\n \"channel.preset.opencode-zen.description\": \"OpenCode's curated model gateway with the Go subscription and Zen pay-as-you-go plans sharing the same coding model preset.\",\n \"channel.preset.opencode-go.label\": \"OpenCode Go\",\n \"channel.preset.opencode-go.description\": \"OpenCode's low-cost open-source coding model subscription (from $5/month), optimized for coding agent scenarios.\",\n \"channel.preset.deepseek.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.deepseek.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.deepseek.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.deepseek.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.preset.mimo.plan.anthropic.label\": \"Pay-as-you-go (Anthropic)\",\n \"channel.preset.mimo.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.mimo.plan.openai-chat.label\": \"Pay-as-you-go (OpenAI)\",\n \"channel.preset.mimo.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.preset.mimo.plan.token-cn.label\": \"Token Plan - China (OpenAI)\",\n \"channel.preset.mimo.plan.token-cn.description\": \"China Token Plan Chat / Responses shared endpoint\",\n \"channel.preset.mimo.plan.token-sgp.label\": \"Token Plan - Singapore (OpenAI)\",\n \"channel.preset.mimo.plan.token-sgp.description\": \"Singapore Token Plan Chat / Responses shared endpoint\",\n \"channel.preset.mimo.plan.token-ams.label\": \"Token Plan - Europe (OpenAI)\",\n \"channel.preset.mimo.plan.token-ams.description\": \"Europe Token Plan Chat / Responses shared endpoint\",\n \"channel.preset.mimo.plan.token-cn-anthropic.label\": \"Token Plan - China (Anthropic)\",\n \"channel.preset.mimo.plan.token-cn-anthropic.description\": \"China Token Plan Claude Messages native endpoint\",\n \"channel.preset.mimo.plan.token-sgp-anthropic.label\": \"Token Plan - Singapore (Anthropic)\",\n \"channel.preset.mimo.plan.token-sgp-anthropic.description\": \"Singapore Token Plan Claude Messages native endpoint\",\n \"channel.preset.mimo.plan.token-ams-anthropic.label\": \"Token Plan - Europe (Anthropic)\",\n \"channel.preset.mimo.plan.token-ams-anthropic.description\": \"Europe Token Plan Claude Messages native endpoint\",\n \"channel.preset.compshare.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.compshare.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.compshare.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.compshare.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.preset.runapi.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.runapi.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.runapi.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.runapi.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.preset.unity2.label\": \"Unity2.ai\",\n \"channel.preset.unity2.description\": \"Unity2.ai is a high-performance AI model API relay platform for individual developers, teams, and enterprises. It serves leading domestic enterprises long-term, handles over 30 billion tokens daily, and supports 5000 RPM-level high concurrency. One API Key works seamlessly with Claude Code, Codex, OpenAI models, IDE plugins, and Agent workflows. With enterprise-grade stable supply capability, it maintains low latency and high availability under high concurrency, continuous calling, and team bulk procurement scenarios. Register now to claim $2 balance, join the official group for an additional $10 balance, up to $12 free credits total.\",\n \"channel.preset.unity2.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.unity2.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.unity2.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.unity2.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.preset.kimi.plan.anthropic.label\": \"Pay-as-you-go (Anthropic)\",\n \"channel.preset.kimi.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.kimi.plan.openai-chat.label\": \"Pay-as-you-go (OpenAI)\",\n \"channel.preset.kimi.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.preset.kimi.plan.coding-anthropic.label\": \"Coding Plan (Anthropic)\",\n \"channel.preset.kimi.plan.coding-anthropic.description\": \"Coding Plan Claude Messages native endpoint\",\n \"channel.preset.kimi.plan.coding-openai-chat.label\": \"Coding Plan (OpenAI)\",\n \"channel.preset.kimi.plan.coding-openai-chat.description\": \"Coding Plan Chat / Responses shared endpoint\",\n \"channel.preset.glm.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.glm.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.glm.plan.coding.label\": \"Coding Plan (OpenAI)\",\n \"channel.preset.glm.plan.coding.description\": \"Coding Plan Chat / Responses shared endpoint\",\n \"channel.preset.glm.plan.openai-chat.label\": \"General (OpenAI)\",\n \"channel.preset.glm.plan.openai-chat.description\": \"General Chat / Responses endpoint\",\n \"channel.preset.sensenova.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.sensenova.plan.anthropic.description\": \"Claude Messages compatible endpoint\",\n \"channel.preset.sensenova.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.sensenova.plan.openai-chat.description\": \"Chat endpoint; Responses are converted by CCX\",\n \"channel.preset.minimax.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.minimax.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.minimax.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.minimax.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.preset.dashscope.plan.anthropic.label\": \"Pay-as-you-go (Anthropic)\",\n \"channel.preset.dashscope.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.dashscope.plan.openai-chat.label\": \"Pay-as-you-go (OpenAI)\",\n \"channel.preset.dashscope.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.preset.dashscope.plan.coding-anthropic.label\": \"Coding Plan (Anthropic)\",\n \"channel.preset.dashscope.plan.coding-anthropic.description\": \"Coding Plan Claude Messages native endpoint\",\n \"channel.preset.dashscope.plan.coding-openai-chat.label\": \"Coding Plan (OpenAI)\",\n \"channel.preset.dashscope.plan.coding-openai-chat.description\": \"Coding Plan Chat / Responses shared endpoint\",\n \"channel.preset.dashscope.plan.token-plan-anthropic.label\": \"Token Plan (Anthropic)\",\n \"channel.preset.dashscope.plan.token-plan-anthropic.description\": \"Token Plan Claude Messages native endpoint\",\n \"channel.preset.dashscope.plan.token-plan-openai-chat.label\": \"Token Plan (OpenAI)\",\n \"channel.preset.dashscope.plan.token-plan-openai-chat.description\": \"Token Plan Chat / Responses shared endpoint\",\n \"channel.preset.tencent-lkeap.label\": \"Tencent Cloud TokenHub\",\n \"channel.preset.tencent-lkeap.description\": \"Tencent Cloud's LLM service platform integrating Hunyuan and mainstream models. Token Plan subscriptions and pay-as-you-go billing, compatible with Claude Code, Codex, and other coding tools.\",\n \"channel.preset.tencent-lkeap.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.tencent-lkeap.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.tencent-lkeap.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.tencent-lkeap.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.preset.kimi-code.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.kimi-code.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.kimi-code.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.kimi-code.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.preset.volc-ark.label\": \"Volcengine Ark Coding Plan\",\n \"channel.preset.volc-ark.description\": \"ByteDance's one-stop AI platform under Volcengine. Full-chain services for Doubao series models including fine-tuning, inference, and evaluation. Multi-vendor model support with enterprise-grade reliability.\",\n \"channel.preset.volc-ark.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.volc-ark.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.volc-ark.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.volc-ark.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.preset.qianfan.label\": \"Baidu Qianfan Coding Plan\",\n \"channel.preset.qianfan.description\": \"Baidu Cloud's enterprise LLM platform with native ERNIE 5.0 multimodal model. Multi-agent collaboration and full-lifecycle tools from development to deployment.\",\n \"channel.preset.qianfan.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.qianfan.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.qianfan.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.qianfan.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.preset.originrouter.label\": \"OriginRouter\",\n \"channel.preset.originrouter.description\": \"Unified model API forwarding platform. A single key to seamlessly switch between GPT, Claude, Gemini, and other models. High-speed and stable, ideal for multi-model mixing.\",\n \"channel.preset.originrouter.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.originrouter.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.originrouter.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.originrouter.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.preset.opencode-zen.plan.go-openai-chat.label\": \"Go subscription\",\n \"channel.preset.opencode-zen.plan.go-openai-chat.description\": \"Low-cost subscription endpoint. Messages are converted by CCX to the Chat upstream; Chat / Responses use this endpoint directly\",\n \"channel.preset.opencode-zen.plan.openai-chat.label\": \"Zen pay-as-you-go\",\n \"channel.preset.opencode-zen.plan.openai-chat.description\": \"Pay-as-you-go endpoint. Messages are converted by CCX to the Chat upstream; Chat / Responses use this endpoint directly\",\n \"channel.preset.opencode-go.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.opencode-go.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.opencode-go.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.opencode-go.plan.openai-chat.description\": \"Messages are converted by CCX to the Chat upstream; Chat / Responses use this endpoint directly\",\n \"channel.preset.modelscope.label\": \"ModelScope\",\n \"channel.preset.modelscope.description\": \"Alibaba DAMO Academy's open-source model community with 10,000+ models across text, image, and speech. Full-lifecycle support for model exploration, inference, and training.\",\n \"channel.preset.modelscope.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.modelscope.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.modelscope.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.modelscope.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.preset.xfyun.label\": \"iFlytek Xinghuo\",\n \"channel.preset.xfyun.description\": \"iFlytek Xinghuo MaaS Astron Coding Plan with separate OpenAI, Anthropic, and Responses Coding Plan endpoints.\",\n \"channel.preset.xfyun.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.xfyun.plan.anthropic.description\": \"Claude Messages Coding Plan endpoint\",\n \"channel.preset.xfyun.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.xfyun.plan.openai-chat.description\": \"OpenAI Chat Coding Plan endpoint\",\n \"channel.preset.xfyun.plan.responses.label\": \"Responses-compatible\",\n \"channel.preset.xfyun.plan.responses.description\": \"OpenAI Responses Coding Plan endpoint\",\n \"channel.preset.openrouter.label\": \"OpenRouter\",\n \"channel.preset.openrouter.description\": \"The largest AI model aggregator — access hundreds of models with a single API key. OpenAI-compatible with built-in guardrails for security, governance, and cost control.\",\n \"channel.preset.openrouter.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.openrouter.plan.anthropic.description\": \"Claude Messages native endpoint\",\n \"channel.preset.openrouter.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.openrouter.plan.openai-chat.description\": \"Common Chat / Responses endpoint\",\n \"channel.target.messages.label\": \"Messages native\",\n \"channel.target.messages.description\": \"Claude Code direct, or CCX messages channel\",\n \"channel.target.responses.label\": \"Codex Responses\",\n \"channel.target.responses.description\": \"OpenAI Responses protocol, for Codex\",\n \"channel.target.chat.label\": \"Chat passthrough\",\n \"channel.target.chat.description\": \"OpenAI Chat protocol, for Chat clients\",\n \"env.title\": \"Environment config\",\n \"env.pathDetecting\": \"Detecting\",\n \"env.refresh\": \"Refresh\",\n \"env.openingEditor\": \"Opening…\",\n \"env.openWithEditor\": \"Open in {editor}\",\n \"env.openInEditor\": \"Open in editor\",\n \"env.selectEditor\": \"Choose editor…\",\n \"env.save\": \"Save\",\n \"env.saving\": \"Saving\",\n \"env.saved\": \"Saved\",\n \"env.failed\": \"Failed\",\n \"env.hide\": \"Hide\",\n \"env.show\": \"Show\",\n \"env.copied\": \"Copied\",\n \"env.copy\": \"Copy\",\n \"env.fieldRequired\": \"{field} is required\",\n \"env.fieldDisallow\": \"{field} cannot use a sample placeholder value\",\n \"env.fieldNumber\": \"{field} must be a number\",\n \"env.fieldInteger\": \"{field} must be an integer\",\n \"env.fieldMin\": \"{field} must be at least {min}\",\n \"env.fieldMax\": \"{field} must be at most {max}\",\n \"env.loadFailed\": \"Failed to load config: {error}\",\n \"env.saveSuccess\": \".env saved; changes take effect after restarting the service\",\n \"agent.statusDetecting\": \"Detecting\",\n \"agent.statusConfigured\": \"Configured\",\n \"agent.statusPortMismatch\": \"Port mismatch\",\n \"agent.statusUnconfigured\": \"Not configured\",\n \"agent.localGateway\": \"Current CCX gateway\",\n \"agent.custom\": \"Custom\",\n \"agent.currentProvider\": \"Current provider\",\n \"agent.currentUrl\": \"Current URL\",\n \"agent.targetUrl\": \"Target URL\",\n \"agent.notSet\": \"Not configured\",\n \"agent.configPath\": \"Config file\",\n \"agent.authPath\": \"Auth file\",\n \"agent.openFileInEditor\": \"Open in editor\",\n \"agent.applyConfig\": \"Apply config\",\n \"agent.restoreConfig\": \"Restore original config\",\n \"agent.migrateSessions\": \"Migrate sessions\",\n \"agent.migrateConfirmTitle\": \"Migrate Codex sessions\",\n \"agent.migrateConfirmDesc\": \"This will rewrite Codex local session metadata to the current provider. It updates history, archived sessions, and Codex state database, but never deletes sessions.\",\n \"agent.migrateTarget\": \"Target model_provider\",\n \"agent.migrateRunning\": \"Migrating sessions…\",\n \"agent.migrateConfirm\": \"Confirm migration\",\n \"agent.migrateClose\": \"Close\",\n \"agent.migrateResultSuccess\": \"Migration complete: {migrated}/{total} session files updated\",\n \"agent.migrateResultSkippedLabel\": \"Skipped files\",\n \"agent.migrateResultFailedLabel\": \"Failed files\",\n \"agent.migrateSQLiteUpdated\": \"SQLite database updated: {count} rows\",\n \"agent.migrateSQLiteSkipped\": \"SQLite database skipped\",\n \"agent.openConsole\": \"Open official console\",\n \"agent.codexPlaceholderSaved\": \"Saved locally; leave empty to reuse the key\",\n \"agent.codexPlaceholderRequired\": \"Required: enter API Key\",\n \"agent.codexPlaceholderWriteOnly\": \"Written to Codex config only\",\n \"agent.diffPreviewApply\": \"Apply config preview\",\n \"agent.diffPreviewRestore\": \"Restore config preview\",\n \"agent.diffConfirmApply\": \"Confirm apply\",\n \"agent.diffConfirmRestore\": \"Confirm restore\",\n \"agent.diffActionCreate\": \"Create\",\n \"agent.diffActionDelete\": \"Delete\",\n \"agent.diffActionModify\": \"Modify\",\n \"agent.diffComputing\": \"Computing changes…\",\n \"agent.diffNoChanges\": \"No changes\",\n \"agent.diffExpandContext\": \"Expand {count} unchanged lines\",\n \"agent.diffCollapseContext\": \"Collapse {count} lines\",\n \"agent.diffCancel\": \"Cancel\",\n \"agent.sessionMigrationWarning\": \"Switching provider mode will make existing sessions invisible in Codex. Sessions are not deleted — switch back to the previous mode to access them.\",\n \"agent.codexMode\": \"Mode\",\n \"agent.codexQuickMode\": \"Quick mode\",\n \"agent.codexPluginMode\": \"Plugin mode\",\n \"agent.codexQuickModeHint\": \"Keeps OpenAI conversation history. Uses OpenAI as provider with the configured API endpoint.\",\n \"agent.codexPluginModeHint\": \"Enables Codex plugins. Previous OpenAI sessions may not be visible — switch back to Quick mode to access them.\",\n \"agent.provider.localGateway\": \"CCX local gateway\",\n \"agent.provider.deepseekDirect\": \"DeepSeek direct\",\n \"agent.provider.mimoDirect\": \"MiMo direct\",\n \"agent.provider.compshareDirect\": \"Compshare direct\",\n \"agent.provider.runapiDirect\": \"RunAPI direct\",\n \"agent.provider.unity2Direct\": \"Unity2.ai direct\",\n \"agent.provider.kimiDirect\": \"Kimi direct\",\n \"agent.provider.glmDirect\": \"GLM direct\",\n \"agent.provider.sensenovaDirect\": \"SenseNova direct\",\n \"agent.provider.minimaxDirect\": \"MiniMax direct\",\n \"agent.provider.dashscopeDirect\": \"DashScope direct\",\n \"agent.provider.opencodeZenDirect\": \"OpenCode Zen direct\",\n \"agent.provider.opencodeGoDirect\": \"OpenCode Go direct\",\n \"agent.provider.openaiDirect\": \"OpenAI direct\",\n \"agent.provider.openrouterDirect\": \"OpenRouter direct\",\n \"agent.provider.modelscopeDirect\": \"ModelScope direct\",\n \"agent.provider.xfyunDirect\": \"iFlytek Xinghuo MAAS direct\",\n \"agent.provider.tencentLkeapDirect\": \"Tencent TokenHub direct\",\n \"agent.provider.volcArkDirect\": \"Volc Ark direct\",\n \"agent.provider.qianfanDirect\": \"Qianfan direct\",\n \"agent.hasOwnApiKey\": \"I have my own API key\",\n \"agent.promo\": \"Register via promotion link to claim the exclusive perk\",\n \"agent.planPayAsYouGo\": \"Pay-as-you-go\",\n \"agent.planChina\": \"Subscription - China\",\n \"agent.planSingapore\": \"Subscription - Singapore\",\n \"agent.planEurope\": \"Subscription - Europe\",\n \"agent.planSubscription\": \"Subscription\",\n \"agent.billingModeMiMo\": \"MiMo billing mode\",\n \"agent.billingModeDashScope\": \"DashScope billing mode\",\n \"agent.placeholderSaved\": \"Saved locally; leave empty to reuse the key\",\n \"agent.placeholderMimo\": \"Required: MiMo API Key (tp-xxx or account key)\",\n \"agent.placeholderDashScope\": \"Required: DashScope API Key (sk-xxx or sk-sp-xxx)\",\n \"agent.placeholderRequired\": \"Required: enter API Key\",\n \"agent.codexDiagnosticTitle\": \"Codex troubleshooting\",\n \"agent.codexTroubleshoot\": \"Troubleshoot\",\n \"agent.codexTroubleshooting\": \"Checking...\",\n \"agent.codexDiagnosticHealthy\": \"Current Codex config looks consistent with CCX.\",\n \"agent.codexDiagnosticMismatch\": \"config.toml and auth.json may be inconsistent.\",\n \"agent.codexDiagnosticCheck\": \"Check item\",\n \"agent.codexDiagnosticAuthMode\": \"Auth mode\",\n \"agent.codexDiagnosticConfigPath\": \"config.toml\",\n \"agent.codexDiagnosticAuthPath\": \"auth.json\",\n \"agent.codexDiagnosticSuggestedApply\": \"Suggested action: re-apply Codex -> CCX config.\",\n \"agent.codexDiagnosticSuggestedRestore\": \"If the issue persists, restore first and then apply again.\",\n \"agent.codexDiagnosticStatusTitle\": \"Codex diagnostic summary\",\n \"agent.codexDiagnosticStatusHint\": \"Open the Agent tab to inspect Codex config consistency.\",\n \"agent.codexDiagnosticCodeMissingApiKey\": \"config.toml points to local CCX, but auth.json is missing OPENAI_API_KEY.\",\n \"agent.codexDiagnosticCodeProxyKeyMismatch\": \"The proxy key stored in Codex no longer matches the current CCX proxy key.\",\n \"agent.codexDiagnosticCodeAuthModeMismatch\": \"config.toml and auth.json disagree on the required auth mode.\",\n \"agent.codexDiagnosticCodePluginMissingBearer\": \"Plugin mode is missing experimental_bearer_token.\",\n \"agent.codexDiagnosticCodeLegacyIncomplete\": \"Legacy ccx provider config is incomplete.\",\n \"agent.codexDiagnosticCodeConfigPolluted\": \"config.toml may have been polluted by another tool and no longer matches CCX expectations.\",\n \"agent.codexDiagnosticCodeAuthUnreadable\": \"auth.json cannot be parsed and may be corrupted.\",\n \"agent.codexDiagnosticCodeModeMismatch\": \"Current Codex config is {currentMode} mode, but you selected {targetMode} mode. Please re-apply the config.\",\n \"agent.responsesDiagnosticTitle\": \"Responses channel diagnostic\",\n \"agent.responsesDiagnosticHealthy\": \"Responses channels look available.\",\n \"agent.responsesDiagnosticNoChannel\": \"No Responses channel is configured in CCX.\",\n \"agent.responsesDiagnosticAllDisabled\": \"Responses channels exist, but none is currently active.\",\n \"agent.responsesDiagnosticNoApiKeys\": \"Active Responses channels are missing API keys.\",\n \"agent.responsesDiagnosticProtocolMismatch\": \"Responses channels exist, but their protocol routing does not look compatible with /v1/responses.\",\n \"agent.responsesDiagnosticCircuitOpen\": \"All active Responses channels are currently circuit-open.\",\n \"agent.responsesDiagnosticSuggestedRefresh\": \"Suggested action: refresh channel dashboard and logs.\",\n \"agent.responsesDiagnosticSuggestedCreate\": \"Suggested action: add a Responses channel for your Codex provider.\",\n \"agent.responsesDiagnosticSuggestedEnable\": \"Suggested action: enable or resume at least one Responses channel.\",\n \"agent.responsesDiagnosticSuggestedAddKey\": \"Suggested action: add an API key to an active Responses channel.\",\n \"agent.responsesDiagnosticSuggestedProtocolMismatch\": \"Suggested action: check serviceType and baseUrl so the channel resolves to a /responses endpoint.\",\n \"agent.responsesDiagnosticSuggestedResume\": \"Suggested action: inspect logs and resume the affected channels after recovery.\",\n \"agent.responsesLogsDiagnosticTitle\": \"Recent failed requests\",\n \"agent.responsesLogsDiagnosticHealthy\": \"No recent failed Responses requests were found.\",\n \"agent.responsesLogsDiagnosticAuth\": \"Recent Responses requests failed with authentication errors (401/403).\",\n \"agent.responsesLogsDiagnosticRateLimit\": \"Recent Responses requests were rate limited (429).\",\n \"agent.responsesLogsDiagnosticUpstream5xx\": \"Recent Responses requests failed with upstream 5xx errors.\",\n \"agent.responsesLogsDiagnosticTimeout\": \"Recent Responses requests look stalled or timed out.\",\n \"agent.responsesLogsDiagnosticGeneric\": \"Recent Responses requests failed. Check channel logs for details.\",\n \"agent.responsesLogsDiagnosticSuggestedRefresh\": \"Suggested action: refresh the latest channel logs.\",\n \"agent.responsesLogsDiagnosticSuggestedAuth\": \"Suggested action: verify the upstream API key, account quota, and auth settings.\",\n \"agent.responsesLogsDiagnosticSuggestedRateLimit\": \"Suggested action: reduce request rate or wait for the upstream limit window to recover.\",\n \"agent.responsesLogsDiagnosticSuggestedUpstream5xx\": \"Suggested action: switch channel, inspect upstream status, or retry later.\",\n \"agent.responsesLogsDiagnosticSuggestedTimeout\": \"Suggested action: inspect upstream latency, timeout settings, and recent logs.\",\n \"agent.responsesLogsDiagnosticSuggestedGeneric\": \"Suggested action: open the Responses channel logs and inspect the latest failures.\",\n \"agent.codexDiagnosticIntro\": \"Troubleshooting runs on demand: config consistency → Responses channel availability → recent failed requests.\",\n \"agent.codexDiagnosticLayerConfig\": \"Layer 1 · Codex config\",\n \"agent.codexDiagnosticLayerChannels\": \"Layer 2 · Responses channels\",\n \"agent.codexDiagnosticLayerLogs\": \"Layer 3 · Recent failed requests\",\n \"env.groupAccess\": \"Access control\",\n \"env.groupAccessDesc\": \"Access keys for the proxy and admin endpoints.\",\n \"env.fieldProxyAccessKey\": \"Proxy access key\",\n \"env.placeholderProxyAccessKey\": \"Enter a strong random key\",\n \"env.fieldAdminAccessKey\": \"Admin API key\",\n \"env.placeholderAdminAccessKey\": \"Leave empty to fall back to PROXY_ACCESS_KEY\",\n \"env.descAdminAccessKey\": \"Used for the Web UI and /api/* endpoints.\",\n \"env.groupServer\": \"Server config\",\n \"env.groupServerDesc\": \"Desktop injects some runtime values on start; this section still matches .env.example.\",\n \"env.fieldPort\": \"Port\",\n \"env.descPort\": \"Preferred startup port. If occupied, the next available port is used automatically.\",\n \"env.fieldBindHost\": \"Bind host\",\n \"env.descBindHost\": \"Leave empty to listen on all interfaces (:PORT / 0.0.0.0). Use 127.0.0.1 for local-only access.\",\n \"env.fieldEnableHttps\": \"Enable local HTTPS\",\n \"env.descEnableHttps\": \"For clients such as Claude Desktop that only accept HTTPS gateways. WebURL and health checks switch to https when enabled.\",\n \"env.fieldTlsAutoCert\": \"Auto-generate local cert\",\n \"env.descTlsAutoCert\": \"Generates a temporary localhost self-signed certificate when cert files are not provided. Use cert files if the client requires a trusted certificate.\",\n \"env.fieldTlsCertFile\": \"TLS certificate file\",\n \"env.placeholderTlsCertFile\": \"Example: /Users/me/.config/ccx/localhost.pem\",\n \"env.descTlsCertFile\": \"PEM certificate path. Set together with TLS_KEY_FILE.\",\n \"env.fieldTlsKeyFile\": \"TLS private key file\",\n \"env.placeholderTlsKeyFile\": \"Example: /Users/me/.config/ccx/localhost-key.pem\",\n \"env.descTlsKeyFile\": \"PEM private key path. Set together with TLS_CERT_FILE.\",\n \"env.fieldEnv\": \"Environment\",\n \"env.descEnv\": \"production is recommended.\",\n \"env.groupWebUI\": \"Web UI config\",\n \"env.groupWebUIDesc\": \"Control whether the Web UI is enabled and which language it uses by default.\",\n \"env.fieldEnableWebUI\": \"Enable Web UI\",\n \"env.descEnableWebUI\": \"Desktop mode usually forces this on.\",\n \"env.fieldAppUILanguage\": \"Default language\",\n \"env.groupLogs\": \"Logging config\",\n \"env.groupLogsDesc\": \"Control request/response logs, SSE debugging, and response model rewriting.\",\n \"env.fieldLogLevel\": \"Log level\",\n \"env.fieldEnableRequestLogs\": \"Enable request logs\",\n \"env.fieldEnableResponseLogs\": \"Enable response logs\",\n \"env.descEnableResponseLogs\": \"Response logs may expose sensitive content.\",\n \"env.fieldQuietPollingLogs\": \"Quiet polling logs\",\n \"env.fieldRawLogOutput\": \"Raw log output\",\n \"env.fieldSseDebugLevel\": \"SSE debug level\",\n \"env.fieldRewriteResponseModel\": \"Rewrite response model\",\n \"env.groupPerformance\": \"Performance config\",\n \"env.groupPerformanceDesc\": \"Request-chain timeouts and request body size limits.\",\n \"env.fieldServerReadTimeout\": \"Server read timeout (ms)\",\n \"env.fieldMaxRequestBodySize\": \"Max request body size (MB)\",\n \"env.groupCors\": \"CORS config\",\n \"env.groupCorsDesc\": \"Cross-origin access control.\",\n \"env.fieldEnableCors\": \"Enable CORS\",\n \"env.fieldCorsOrigin\": \"Allowed Origin\",\n \"env.groupCircuitBreaker\": \"Circuit-breaker metrics config\",\n \"env.groupCircuitBreakerDesc\": \"Control scheduler metric windows and failure-rate thresholds.\",\n \"env.fieldMetricsWindowSize\": \"Window size\",\n \"env.fieldMetricsFailureThreshold\": \"Failure-rate threshold\",\n \"env.runtimeCbTitle\": \"Tuning Bench\",\n \"env.runtimeCbDesc\": \"Fine-tune circuit breaker and stream control parameters. Changes take effect immediately.\",\n \"env.runtimeCbWindowSize\": \"Sliding Window Size\",\n \"env.runtimeCbWindowSizeDesc\": \"Number of recent requests for failure rate (3-100)\",\n \"env.runtimeCbFailureThreshold\": \"Failure Rate Threshold\",\n \"env.runtimeCbFailureThresholdDesc\": \"Triggers circuit break when exceeded (0.01-1.0)\",\n \"env.runtimeCbConsecutiveFailures\": \"Consecutive Failures Threshold\",\n \"env.runtimeCbConsecutiveFailuresDesc\": \"Triggers circuit break on N consecutive retryable failures (1-100)\",\n \"env.runtimeCbRequestTimeout\": \"Non-stream request timeout\",\n \"env.runtimeCbResponseHeaderTimeout\": \"Response header timeout\",\n \"env.runtimeCbStreamFirstContentTimeout\": \"First Content Timeout\",\n \"env.runtimeCbStreamFirstContentTimeoutDesc\": \"Wait for first valid content after HTTP 200 (0=disabled, 1000-300000ms)\",\n \"env.runtimeCbStreamInactivityTimeout\": \"Inactivity Timeout\",\n \"env.runtimeCbStreamInactivityTimeoutDesc\": \"Wait for subsequent activity after first content (0=disabled, 1000-180000ms)\",\n \"env.runtimeCbStreamToolCallIdleTimeout\": \"Tool Call Idle Timeout\",\n \"env.runtimeCbStreamToolCallIdleTimeoutDesc\": \"Wait for valid output while a tool call is pending (1000-180000ms)\",\n \"env.runtimeCbPresetGentle\": \"Gentle\",\n \"env.runtimeCbPresetBalanced\": \"Balanced\",\n \"env.runtimeCbPresetAggressive\": \"Aggressive\",\n \"env.runtimeCbPresetCustom\": \"Custom\",\n \"env.runtimeCbSaved\": \"Tuning Bench settings saved. Changes take effect immediately.\",\n \"env.runtimeCbSaveFailed\": \"Failed to save: {error}\",\n \"env.runtimeCbLoadFailed\": \"Failed to load: {error}\",\n \"env.runtimeCbNoBackend\": \"Backend service not running. Start the service first.\",\n \"env.runtimeCbServiceStopped\": \"Service is stopped. Start it to configure runtime parameters.\",\n \"env.groupMetricsPersistence\": \"Metrics persistence config\",\n \"env.groupMetricsPersistenceDesc\": \"Control SQLite metrics persistence and data retention.\",\n \"env.fieldMetricsPersistenceEnabled\": \"Enable metrics persistence\",\n \"env.fieldMetricsRetentionDays\": \"Metrics retention days\",\n \"logs.searchPlaceholder\": \"Search logs...\",\n \"logs.autoScroll\": \"Auto-scroll to bottom\",\n \"logs.copied\": \"Copied!\",\n \"logs.copyAll\": \"Copy all logs\",\n \"logs.clear\": \"Clear log console\",\n \"logs.noSearchResults\": \"No matching log lines found\",\n \"logs.empty\": \"No logs yet. Start the service to view output.\",\n \"diagnostic.binaryTitle\": \"Binary not found\",\n \"diagnostic.binarySuggestionBuild\": \"Confirm the CCX binary has been built: cd backend-go && make build\",\n \"diagnostic.binarySuggestionCheckDataDir\": \"Check whether ccx-go / ccx-go.exe exists in the Desktop data directory\",\n \"diagnostic.binarySuggestionDownload\": \"Build the backend first, or download a prebuilt release from the Release page\",\n \"diagnostic.portTitle\": \"Port conflict\",\n \"diagnostic.portSuggestionInstance\": \"Check whether another CCX instance is already running\",\n \"diagnostic.portSuggestionEnv\": \"Change the PORT field in .env to another port\",\n \"diagnostic.portSuggestionInspect\": \"Use lsof -i :3688 (macOS/Linux) or netstat -ano | findstr :3688 (Windows) to inspect port usage\",\n \"diagnostic.healthTitle\": \"Health check timeout\",\n \"diagnostic.healthSuggestionLogs\": \"Check the log panel for startup errors\",\n \"diagnostic.healthSuggestionEnv\": \"Check whether .env has syntax errors\",\n \"diagnostic.healthSuggestionChannels\": \"Confirm upstream channel configuration is correct. First startup may take longer.\",\n \"diagnostic.healthSuggestionRestart\": \"Try restarting the service manually\",\n \"diagnostic.permissionTitle\": \"Insufficient permissions\",\n \"diagnostic.permissionSuggestionDataDir\": \"Check whether the data directory is writable\",\n \"diagnostic.permissionSuggestionExecutable\": \"macOS/Linux: confirm the binary is executable (chmod +x)\",\n \"diagnostic.permissionSuggestionWindows\": \"Windows: try running as administrator\",\n \"diagnostic.genericTitle\": \"Startup failed\",\n \"diagnostic.genericSuggestionLogs\": \"Check the log panel below for detailed errors\",\n \"diagnostic.genericSuggestionRestart\": \"Try restarting the service\",\n \"setup.errorEmptyKey\": \"PROXY_ACCESS_KEY cannot be empty\",\n \"env.saveSuccessHint\": \".env saved; changes take effect after restarting the service\",\n \"env.openedInEditor\": \".env opened in editor\",\n \"sidebar.versionHintStore\": \"Microsoft Store version updates automatically via Store\",\n \"sidebar.versionHintTray\": \"Check for updates from the tray menu\",\n \"sidebar.versionClickCheck\": \"Click to check for updates\",\n \"sidebar.updateAvailable\": \"New {version}\",\n \"sidebar.updateAvailableHint\": \"Click to view release notes\",\n \"sidebar.updateInstallHint\": \"Click to install update\",\n \"sidebar.updateOpenReleaseHint\": \"Click to view release notes\",\n \"sidebar.theme\": \"Theme\",\n \"sidebar.themeAuto\": \"System\",\n \"sidebar.themeLight\": \"Light\",\n \"sidebar.themeDark\": \"Dark\",\n \"app.tabs.messages\": \"Channels\",\n \"app.tabs.conversations\": \"Cockpit\",\n \"console.nativeChannelsEyebrow\": \"Native channel orchestration\",\n \"console.nativeChannelsTitle\": \"WebUI-aligned protocol workspace\",\n \"console.quickAdd\": \"Quick Add\",\n \"console.quickAddEyebrow\": \"Provider Presets\",\n \"console.quickAddTitle\": \"Quick Add Channel\",\n \"console.actions.deleting\": \"Deleting...\",\n \"common.refreshing\": \"Refreshing\",\n \"common.copy\": \"Copy\",\n \"common.copied\": \"Copied\",\n \"common.delete\": \"Delete\",\n \"common.add\": \"Add\",\n \"orchestration.searchPlaceholder\": \"Search channels...\",\n \"orchestration.noMatchingChannels\": \"No matching channels\",\n \"app.actions.refresh\": \"Refresh\",\n \"app.actions.ping\": \"Test latency\",\n \"app.actions.addChannel\": \"Add channel\",\n \"orchestration.noActiveChannels\": \"No channels yet. Click the button above to add one.\",\n \"orchestration.multiChannel\": \"Multi-channel mode\",\n \"orchestration.singleChannel\": \"Single-channel mode\",\n \"orchestration.failoverSequence\": \"Failover sequence\",\n \"orchestration.standbyPool\": \"Standby pool\",\n \"orchestration.enable\": \"Enable\",\n \"channelCard.suspended\": \"Paused\",\n \"channelCard.actions\": \"Actions\",\n \"orchestration.moveToPool\": \"Move to standby pool\",\n \"orchestration.cache\": \"Cache\",\n \"orchestration.cacheWriteHigh\": \"Cache write high\",\n \"orchestration.cacheWriteHighHint\": \"The cache write ratio is high in the last 15 minutes, which may indicate suboptimal cache-hit configuration.\",\n \"chart.globalStats\": \"Usage Stats\",\n \"status.normal\": \"Normal\",\n \"status.paused\": \"Paused\",\n \"status.tripped\": \"Tripped\",\n \"status.disabled\": \"Disabled\",\n \"channelCard.apiKeyManagement\": \"API key management\",\n \"channelCard.configuredKeys\": \"Configured keys\",\n \"orchestration.edit\": \"Edit\",\n \"capability.startTest\": \"Start test\",\n \"orchestration.logs\": \"Logs\",\n \"orchestration.copyConfig\": \"Copy config\",\n \"orchestration.openWebsite\": \"Open website\",\n \"orchestration.pause\": \"Pause\",\n \"orchestration.resume\": \"Resume\",\n \"orchestration.promotion\": \"Boost priority (5 min)\",\n \"orchestration.delete\": \"Delete\",\n \"orchestration.resumeReset\": \"Resume (reset metrics)\",\n \"orchestration.moveTop\": \"Move to top\",\n \"orchestration.moveBottom\": \"Move to bottom\",\n \"orchestration.keepOne\": \"(keep at least one)\",\n \"orchestration.deleteActiveGuard\": \"Cannot delete: keep at least one active channel in the failover sequence\",\n \"channelCard.disabledKeys\": \"Blacklisted keys\",\n \"channelCard.blacklistReason.authentication_error\": \"Auth error\",\n \"channelCard.blacklistReason.permission_error\": \"Permission denied\",\n \"channelCard.blacklistReason.insufficient_balance\": \"No balance\",\n \"channelCard.blacklistReason.insufficient_quota\": \"Quota exhausted\",\n \"channelCard.blacklistReason.unavailable\": \"Unavailable\",\n \"channelCard.blacklistReason.rate_limited\": \"Rate limited\",\n \"channelCard.blacklistReason.invalid\": \"Invalid key\",\n \"channelCard.blacklistReason.unknown\": \"Unknown\",\n \"channelEditor.title.create\": \"Add channel\",\n \"channelEditor.title.edit\": \"Edit channel\",\n \"channelEditor.managed.providerLabel\": \"Channel provider\",\n \"channelEditor.managed.officialChannel\": \"Official {provider} channel\",\n \"channelEditor.managed.officialBadge\": \"Official managed\",\n \"channelEditor.managed.editSubtitle\": \"Official {provider} channel · Manage account credentials\",\n \"channelEditor.managed.providerChannel\": \"Managed {provider} channel\",\n \"channelEditor.managed.managedBadge\": \"Auto-managed\",\n \"channelEditor.managed.providerEditSubtitle\": \"Managed {provider} channel · Manage account credentials\",\n \"channelEditor.managed.customEditSubtitle\": \"Auto-managed channel · Manage name and account credentials\",\n \"channelEditor.nav.basic\": \"Basic Info\",\n \"channelEditor.basic.name.label\": \"Name\",\n \"channelEditor.basic.name.required\": \"Channel name is required\",\n \"channelEditor.basic.serviceType.label\": \"Upstream type\",\n \"channelEditor.basic.serviceType.placeholder\": \"Select upstream type\",\n \"channelEditor.basic.serviceType.required\": \"Please select an upstream type\",\n \"channelEditor.basic.description.label\": \"Description\",\n \"channelEditor.nav.connection\": \"Connection\",\n \"channelEditor.basic.baseUrl.label\": \"Base URL\",\n \"channelEditor.basic.baseUrl.required\": \"At least one Base URL is required\",\n \"channelEditor.basic.additionalUrls.label\": \"Additional URLs (one per line)\",\n \"channelEditor.transport.proxyUrl.label\": \"Proxy URL\",\n \"channelEditor.transport.routePrefix.label\": \"Route prefix\",\n \"channelEditor.transport.skipTls.label\": \"Skip TLS verification\",\n \"channelEditor.nav.auth\": \"Authentication\",\n \"channelEditor.auth.keys.label\": \"API Keys\",\n \"channelEditor.auth.disabledKeys.label\": \"Disabled keys\",\n \"channelEditor.auth.restoreKey\": \"Restore\",\n \"channelEditor.auth.copyKey\": \"Copy key\",\n \"channelEditor.auth.deleteKey\": \"Delete key\",\n \"channelEditor.auth.addKey\": \"Add key\",\n \"channelEditor.auth.historicalKeys\": \"historical keys recorded\",\n \"channelEditor.advanced.authHeader.label\": \"Auth header\",\n \"channelEditor.advanced.authHeader.hint\": \"Override how the upstream API key is sent; Auto keeps the existing compatibility logic\",\n \"channelEditor.advanced.authHeader.auto\": \"Auto\",\n \"addChannel.testCapability\": \"Test capability\",\n \"addChannel.oneClickSetup\": \"One-click setup\",\n \"addChannel.commonFilters\": \"Common filters\",\n \"addChannel.autoConvertModelNames\": \"Automatically convert model names\",\n \"addChannel.channelName\": \"Channel name\",\n \"addChannel.apiKeys\": \"API Keys\",\n \"addChannel.detectedKeys\": \"Detected {count} API key(s)\",\n \"addChannel.enterApiKey\": \"Enter at least one API key\",\n \"copilotOAuth.quickAddKeyHint\": \"No pasted API key is required. Sign in with GitHub Copilot after creating the channel.\",\n \"addChannel.newChannelPlacementLabel\": \"Append to end\",\n \"addChannel.expectedRequest\": \"Expected request\",\n \"addChannel.apiKeyLoadBalance\": \"Multiple keys can be added as failover candidates\",\n \"addChannel.checking\": \"Checking...\",\n \"addChannel.modelsCount\": \"models {statusCode} ({count})\",\n \"channelEditor.auth.apiKeyRequired\": \"At least one API Key required\",\n \"addChannel.supportedModelsLabel\": \"Supported models (optional)\",\n \"addChannel.supportedModelsPlaceholder\": \"Press Enter after each rule, for example gpt-4*, *image*, or !*image*\",\n \"addChannel.supportedModelsHint\": \"Matches the original model requested by the client before model redirect is applied. Leave empty to allow all models. Supported forms: exact, prefix*, *suffix, *contains*, and exclusion with !.\",\n \"addChannel.supportedModelsInvalidPattern\": \"Invalid rule ignored. Only exact, prefix*, *suffix, *contains*, and ! exclusion are allowed.\",\n \"addChannel.contextCapabilityTitle\": \"Context capability\",\n \"addChannel.defaultContextWindowLabel\": \"Default context window tokens\",\n \"addChannel.defaultContextWindowHint\": \"Used when the actual model is not covered by the built-in registry or JSON overrides. Empty uses the unknown-model policy.\",\n \"addChannel.defaultMaxOutputLabel\": \"Default max output tokens\",\n \"addChannel.defaultMaxOutputHint\": \"Only filters requests that explicitly set an output limit.\",\n \"addChannel.modelCapabilitiesJsonLabel\": \"Actual model capability JSON\",\n \"addChannel.modelCapabilitiesJsonHint\": \"Keys use the actual model name after model redirect and may use wildcards. Empty object means no built-in override.\",\n \"addChannel.modelCapabilitiesJsonInvalid\": \"Enter a valid JSON object. Values may only include contextWindowTokens, maxOutputTokens, thinkingMode, and reasoningEfforts.\",\n \"addChannel.allowUnknownContextLabel\": \"Allow unknown models for large context\",\n \"addChannel.allowUnknownContextHint\": \"By default, unknown-capability models are candidates only up to 200K tokens. Enable this to allow larger requests.\",\n \"addChannel.visionFallbackLabel\": \"Vision fallback model\",\n \"addChannel.visionFallbackPlaceholder\": \"Model to use when request contains images\",\n \"addChannel.visionFallbackHint\": \"When a request contains images and the target model does not support vision, this model will be used instead. Leave empty to failover to the next channel.\",\n \"addChannel.visionFallbackReasoningLabel\": \"Fallback reasoning effort\",\n \"addChannel.visionFallbackReasoningHint\": \"Reasoning effort used when image requests switch to the vision fallback model.\",\n \"channelEditor.mapping.reasoningEffort.label\": \"Reasoning effort\",\n \"channelEditor.compat.noVisionModels\": \"No vision models (one per line)\",\n \"channelEditor.compat.visionFallback.label\": \"Vision fallback model\",\n \"channelEditor.mapping.fetchModels\": \"Fetch models\",\n \"channelEditor.mapping.fetchingModels\": \"Fetching...\",\n \"channelEditor.mapping.modelFetchNeedsConfig\": \"Base URL and API Key required to fetch models\",\n \"channelEditor.compat.visionEnabled\": \"Vision enabled\",\n \"channelEditor.compat.visionDisabled\": \"Vision disabled\",\n \"channelEditor.mapping.models\": \"Models\",\n \"channelEditor.mapping.configured.label\": \"Configured\",\n \"channelEditor.mapping.supportedModels\": \"Supported models (one per line, empty=all)\",\n \"addChannel.modelHintTip\": \"Click a target model field to load supported upstream models. Detection status for each API key appears in the key list.\",\n \"addChannel.modelMappingHintChat\": \"Configure model name mapping to redirect the requested model to a target model. Example: redirect \\\"gpt-4o\\\" to \\\"gpt-5.4\\\".\",\n \"addChannel.modelMappingHintGemini\": \"Configure model name mapping to redirect the requested model to a target model. Example: redirect \\\"gemini-pro\\\" to \\\"gemini-3.5-flash\\\".\",\n \"addChannel.modelMappingHintResponses\": \"Configure model name mapping to redirect the requested model to a target model. Example: redirect \\\"opus\\\" to \\\"gpt-5.4\\\".\",\n \"addChannel.modelMappingHintVectors\": \"Configure Embeddings model name mapping to redirect the requested vector model to a target model. Example: redirect \\\"text-embedding-3-small\\\" to \\\"jina-embeddings-v2-base-zh\\\".\",\n \"addChannel.modelMappingHintMessages\": \"Configure model name mapping to redirect the requested model to a target model. Example: redirect \\\"opus\\\" to \\\"claude-sonnet-4-5-20250929\\\".\",\n \"addChannel.targetModelPlaceholderChat\": \"e.g. gpt-5.4\",\n \"addChannel.targetModelPlaceholderResponses\": \"e.g. gpt-5.4\",\n \"addChannel.targetModelPlaceholderGemini\": \"e.g. gemini-3.5-flash\",\n \"addChannel.targetModelPlaceholderVectors\": \"e.g. text-embedding-3-small\",\n \"addChannel.targetModelPlaceholderMessages\": \"e.g. claude-opus-4-6\",\n \"addChannel.sourceModelNameTooLong\": \"Custom model name is too long (max 50 characters)\",\n \"addChannel.sourceModelNoSpaces\": \"Model name cannot contain spaces\",\n \"addChannel.sourceModelInvalidChars\": \"Model name can only contain letters, numbers, hyphens, underscores, dots, slashes, colons, @, and +\",\n \"addChannel.fillBaseUrlAndApiKey\": \"Fill in Base URL and at least one API key first\",\n \"addChannel.allApiKeysModelsFailed\": \"No API key could load the model list. Check the error shown for each API key.\",\n \"channelEditor.compat.title\": \"Compatibility\",\n \"channelEditor.compat.diagnose\": \"Diagnose\",\n \"channelEditor.compat.diagnoseApplied\": \"{count} recommendation(s) applied\",\n \"channelEditor.compat.diagnoseNoChange\": \"Current config is already optimal\",\n \"channelEditor.compat.diagnoseFailed\": \"Diagnosis failed, please check channel config\",\n \"channelEditor.compat.diagnoseAppliedShort\": \"{count} applied\",\n \"channelEditor.compat.diagnoseNoChangeShort\": \"Optimal\",\n \"channelEditor.compat.diagnoseFailedShort\": \"Failed\",\n \"channelEditor.compat.reasoningStyle.label\": \"Reasoning parameter style\",\n \"channelEditor.compat.textVerbosity.label\": \"Text verbosity\",\n \"channelEditor.compat.flags\": \"Advanced flags\",\n \"channelEditor.compat.noVision.label\": \"Skip image requests\",\n \"channelEditor.compat.fastMode.label\": \"Fast mode\",\n \"channelEditor.transport.lowQuality.label\": \"Low quality channel\",\n \"channelEditor.compat.injectDummySignature.label\": \"Inject dummy signature\",\n \"channelEditor.compat.stripThoughtSignature.label\": \"Strip thought signature\",\n \"channelEditor.compat.normalizeSystem.label\": \"Normalize system role\",\n \"channelEditor.compat.normalizeUserId.label\": \"Normalize user ID\",\n \"channelEditor.compat.stripBillingHeader.label\": \"Strip billing header\",\n \"channelEditor.runtime.autoBlacklist.label\": \"Auto blacklist abnormal balance keys\",\n \"channelEditor.compat.codexCompat.label\": \"Codex tool compatibility\",\n \"channelEditor.compat.stripCodexTools.label\": \"Strip Codex client tools\",\n \"channelEditor.nav.custom\": \"Custom Parameters\",\n \"channelEditor.transport.requestTimeout.label\": \"Request timeout (s)\",\n \"channelEditor.transport.requestTimeout.invalid\": \"Timeout must be an integer from 1 to 300 seconds\",\n \"channelEditor.transport.responseHeaderTimeout.label\": \"Response header timeout (s)\",\n \"channelEditor.transport.responseHeaderTimeout.invalid\": \"Response header timeout must be an integer from 1 to 300 seconds\",\n \"channelEditor.streamTimeout.title\": \"Stream Timeout\",\n \"channelEditor.streamTimeout.firstContent.overrideLabel\": \"Customize first content timeout\",\n \"channelEditor.streamTimeout.firstContent.label\": \"First byte wait\",\n \"channelEditor.streamTimeout.inactivity.overrideLabel\": \"Customize inactivity timeout\",\n \"channelEditor.streamTimeout.inactivity.label\": \"Post-first-byte idle\",\n \"channelEditor.streamTimeout.toolCallIdle.overrideLabel\": \"Customize tool call idle timeout\",\n \"channelEditor.streamTimeout.toolCallIdle.label\": \"Tool call idle\",\n \"channelEditor.streamTimeout.overrideHint\": \"Custom value overrides the global stream timeout for this channel\",\n \"channelEditor.streamTimeout.inheritHint\": \"Disabled means this channel inherits the global stream timeout\",\n \"channelEditor.actions.cancel\": \"Cancel\",\n \"channelEditor.actions.create\": \"Create\",\n \"channelEditor.actions.save\": \"Save\",\n \"channelEditor.error.channelNotFound\": \"Channel config not found\",\n \"channelEditor.basic.website.label\": \"Website\",\n \"channelEditor.basic.baseUrl.placeholder\": \"One per line, first is primary, the rest are failover\\nhttps://api.example.com\\nhttps://backup.example.com\",\n \"channelEditor.mapping.redirect.title\": \"Model redirect\",\n \"channelEditor.nav.advanced\": \"Advanced Options\",\n \"channelEditor.nav.outline\": \"Outline\",\n \"channelEditor.runtime.title\": \"Runtime\",\n \"channelEditor.transport.title\": \"Transport\",\n \"channelEditor.rateLimit.title\": \"Rate Limit\",\n \"channelEditor.compat.fastMode.hint\": \"Codex /fast mode speeds up generation on supported models for faster coding iteration, but consumes credits or usage limits at a higher rate\",\n \"channelEditor.compat.textVerbosity.style\": \"Text Verbosity Style\",\n \"channelEditor.compat.vision.title\": \"Vision\",\n \"channelEditor.compat.selectDefault\": \"Default\",\n \"channelEditor.compat.autoBlacklistBalance.label\": \"Auto blacklist abnormal balance keys\",\n \"channelEditor.compat.autoBlacklistBalance.hint\": \"Automatically trip/disable keys with balance errors\",\n \"channelEditor.runtime.autoLearnRateLimits.label\": \"Auto learn upstream rate limiting\",\n \"channelEditor.runtime.autoLearnRateLimits.hint\": \"Parse Retry-After / x-ratelimit-* headers and adjust cooldown dynamically\",\n \"channelEditor.rateLimit.rpm.label\": \"Max requests per minute (RPM)\",\n \"channelEditor.rateLimit.rpm.placeholder\": \"No limit\",\n \"channelEditor.rateLimit.window.label\": \"Sliding window (s)\",\n \"channelEditor.rateLimit.window.placeholder\": \"60s\",\n \"channelEditor.rateLimit.maxConcurrent.label\": \"Max concurrent streams\",\n \"channelEditor.rateLimit.maxConcurrent.placeholder\": \"No limit\",\n \"channelEditor.streamTimeout.preset.gentle\": \"Gentle\",\n \"channelEditor.streamTimeout.preset.balanced\": \"Balanced\",\n \"channelEditor.streamTimeout.preset.aggressive\": \"Aggressive\",\n \"channelEditor.streamTimeout.firstContent.hint\": \"Auto disconnect if no response\",\n \"channelEditor.streamTimeout.inactivity.hint\": \"Mid-generation stall timeout\",\n \"channelEditor.streamTimeout.toolCallIdle.hint\": \"FunctionCall delay timeout\",\n \"channelEditor.streamTimeout.timeoutThreshold\": \"Timeout threshold\",\n \"channelEditor.mapping.supportedModels.label\": \"Supported models (whitelist, leave empty for no restriction)\",\n \"channelEditor.mapping.source.duplicate\": \"This source model already has a redirect\",\n \"channelEditor.basic.info.title\": \"Basic info\",\n \"channelEditor.connection.title\": \"Connection\",\n \"channelEditor.basic.multiLineFailover\": \"Multi-line for failover\",\n \"channelEditor.basic.expectedEndpoint\": \"Expected endpoint:\",\n \"channelEditor.basic.description.placeholder\": \"Optional: note about this provider...\",\n \"channelEditor.mapping.hint\": \"Intercept source aliases in requests and route to the real upstream target model\",\n \"channelEditor.compat.visionFallback.hint\": \"Models disabled via the vision toggle on redirect rows will auto-fallback to this model for image requests\",\n \"channelEditor.mapping.addRedirect\": \"Add redirect\",\n \"channelEditor.compat.generationParams\": \"Generation params\",\n \"channelEditor.compat.textVerbosity.placeholder\": \"Default\",\n \"channelEditor.mapping.modelScope\": \"Model scope\",\n \"channelEditor.compat.noVision.hint\": \"When enabled, requests containing images skip this channel and failover to the next one\",\n \"channelEditor.compat.historicalImageLimit.label\": \"Historical image turn limit\",\n \"channelEditor.compat.historicalImageLimit.hint\": \"0 means unlimited; when set to 2-10, images beyond the limit are replaced with placeholder\",\n \"channelEditor.compat.compactModel.label\": \"Compact Model\",\n \"channelEditor.compat.compactModel.hint\": \"Upstream model name for local compact (compression/summary), bypassing model mapping. Always used for non-Responses protocol channels; for Responses channels, used as fallback when native compact fails.\",\n \"channelEditor.compat.stripThoughtSignature.hint\": \"Remove the thought_signature field from functionCall\",\n \"channelEditor.compat.injectDummySignature.hint\": \"Inject a dummy signature into functionCall for third-party APIs\",\n \"channelEditor.compat.reasoningStyle.hint\": \"Choose whether to use reasoning.effort or reasoning_effort for OpenAI-style requests\",\n \"channelEditor.compat.codexCompat.hint\": \"Enable Codex CLI compatibility mode\",\n \"channelEditor.compat.normalizeSystem.hint\": \"Extract system messages from the messages array back to the top-level system field. Native first-party APIs of Chinese models (DeepSeek, Kimi, GLM, MiniMax, etc.) often accept only user/assistant roles and usually need this on; standard Anthropic endpoints or full-featured relays usually don't. Run Diagnose to auto-detect.\",\n \"channelEditor.compat.normalizeUserId.hint\": \"Flatten metadata user ID\",\n \"channelEditor.compat.stripBillingHeader.hint\": \"Remove CCH billing suffix (default on for non-official domains)\",\n \"channelEditor.transport.lowQuality.hint\": \"Mark this channel as low quality, used as last resort\",\n \"channelEditor.runtime.autoBlacklist.hint\": \"Automatically trip/disable keys with balance errors\",\n \"channelEditor.transport.skipTls.hint\": \"Disable TLS certificate verification for upstream\",\n \"channelEditor.transport.proxyUrl.hint\": \"Proxy channel for upstream requests\",\n \"channelEditor.transport.routePrefix.hint\": \"Interface route prefix\",\n \"channelEditor.transport.requestTimeout.hint\": \"Total timeout for non-streaming upstream requests; leave empty to inherit global\",\n \"channelEditor.transport.responseHeaderTimeout.hint\": \"Wait after connection for upstream HTTP headers; relax per slow-start channel only\",\n \"channelEditor.rateLimit.section.label\": \"Rate limiting\",\n \"channelEditor.rateLimit.section.hint\": \"Proactively throttle requests before they reach the upstream to avoid 429 errors.\",\n \"channelEditor.rateLimit.rpm.hint\": \"Requests per minute limit\",\n \"channelEditor.rateLimit.burst.label\": \"Burst\",\n \"channelEditor.rateLimit.burst.hint\": \"Token bucket burst capacity. Auto if empty.\",\n \"channelEditor.rateLimit.maxConcurrent.hint\": \"Full-duplex concurrent stream limit\",\n \"addChannel.duplicateKey\": \"Duplicate key\",\n \"addChannel.addNewApiKeyPlaceholder\": \"Enter the full API key\",\n \"addChannel.customHeadersLabel\": \"Custom headers (optional)\",\n \"addChannel.customHeadersHint\": \"Add or override HTTP headers sent to the upstream service.\",\n \"addChannel.streamFirstContentTimeoutLabel\": \"First content timeout\",\n \"addChannel.streamInactivityTimeoutLabel\": \"Inactivity timeout\",\n \"addChannel.streamToolCallIdleTimeoutLabel\": \"Tool call idle timeout\",\n \"addChannel.streamTimeoutStrategyLabel\": \"Streaming timeout strategy\",\n \"addChannel.streamTimeoutStrategyInherit\": \"Inherit global\",\n \"addChannel.streamTimeoutOverrideHint\": \"Custom value overrides the global stream timeout for this channel.\",\n \"addChannel.streamTimeoutInheritHint\": \"Disabled means this channel inherits the global stream timeout.\",\n \"addChannel.headerNameLabel\": \"Header name\",\n \"addChannel.headerValueLabel\": \"Header value\",\n \"addChannel.descriptionLabel\": \"Description (optional)\",\n \"addChannel.descriptionHint\": \"Optional channel description...\",\n \"addChannel.skipTlsLabel\": \"Skip TLS certificate verification\",\n \"addChannel.skipTlsHint\": \"Enable temporarily only for self-signed certificates or domain mismatch. Keep disabled in production.\",\n \"addChannel.lowQualityLabel\": \"Low-quality channel\",\n \"addChannel.lowQualityHint\": \"When enabled, token usage is force-estimated locally and the local value is used if the deviation exceeds 5%.\",\n \"addChannel.quickMode\": \"Quick Paste\",\n \"addChannel.quickHint\": \"Paste Base URL, API Key, or full config snippet to auto-detect and fill the form.\",\n \"addChannel.applyDetected\": \"Apply detected\",\n \"addChannel.serviceTypeManual\": \"Manual\",\n \"addChannel.serviceTypeDetected\": \"Auto detected\",\n \"addChannel.serviceTypeDefault\": \"Default type\",\n \"addChannel.serviceTypeRecommendedSuffix\": \" · Recommended\",\n \"addChannel.noneDetected\": \"None detected\",\n \"addChannel.fullSubtitle\": \"Configure channel, keys, models and advanced protocol options\",\n \"addChannel.editSubtitle\": \"Modify channel configuration\",\n \"cockpit.searchPlaceholder\": \"Search conversations...\",\n \"cockpit.allKinds\": \"All kinds\",\n \"cockpit.refresh\": \"Refresh\",\n \"cockpit.total\": \"{count} conversations\",\n \"cockpit.empty\": \"No active conversations. Conversations will appear on the radar after requests pass through the gateway.\",\n \"cockpit.noMatches\": \"No matching conversations\",\n \"cockpit.overrideTitle\": \"Channel override\",\n \"cockpit.overrideHint\": \"Select the channel sequence for this conversation (by priority):\",\n \"cockpit.overrideSequence\": \"Override sequence\",\n \"cockpit.overrideRequired\": \"Please select at least one channel\",\n \"cockpit.saveOverride\": \"Save override\",\n \"cockpit.copyRawUserId\": \"Copy conversation ID\",\n \"cockpit.rawUserIdCopied\": \"Conversation ID copied\",\n \"cockpit.rawUserIdCopyFailed\": \"Copy failed\",\n \"cockpit.tooltip.statusStreaming\": \"Status: streaming\",\n \"cockpit.tooltip.statusActive\": \"Status: active\",\n \"cockpit.tooltip.statusIdle\": \"Status: idle\",\n \"cockpit.tooltip.statusUnknown\": \"Status: unknown\",\n \"cockpit.tooltip.kind\": \"Request type: {kind}\",\n \"cockpit.tooltip.requests\": \"Requests: {count}\",\n \"cockpit.tooltip.duration\": \"Duration: {duration}\",\n \"cockpit.tooltip.subagents\": \"Subagents: {count}\",\n \"cockpit.tooltip.parentConversation\": \"Go to parent conversation: {id}\",\n \"cockpit.tooltip.parentThread\": \"Parent thread: {id}\",\n \"cockpit.tooltip.childConversation\": \"Go to subagent: {id}\",\n \"cockpit.tooltip.hiddenChannels\": \"Expand to show {count} more channels\",\n \"cockpit.tooltip.restoreDefault\": \"Restore default channel order\",\n \"cockpit.tooltip.moreSubagents\": \"Expand to show {count} more subagents\",\n \"cockpit.tooltip.clearSubagentOverride\": \"Clear custom subagent routing\",\n \"cockpit.tooltip.quickCurrentChannel\": \"Click to pin current channel\",\n \"cockpit.tooltip.quickNextOverride\": \"Next override target\",\n \"cockpit.tooltip.quickSetNext\": \"Pin as priority channel for this conversation\",\n \"cockpit.tooltip.quickResumeAndSetNext\": \"Resume and pin this channel\",\n \"cockpit.tooltip.quickPinned\": \"Pinned as priority channel; click again to unpin\",\n \"cockpit.tooltip.currentChannel\": \"Currently selected channel\",\n \"cockpit.tooltip.nextChannel\": \"Next override target\",\n \"cockpit.tooltip.nextChannelTripped\": \"Next target is tripped\",\n \"cockpit.tooltip.pausedChannel\": \"Channel is paused and skipped by scheduling\",\n \"cockpit.tooltip.circuitOpen\": \"Circuit breaker is open; this channel is temporarily skipped\",\n \"cockpit.tooltip.channelAlreadyFirst\": \"{name} is already first in the channel order\",\n \"cockpit.tooltip.moveChannelToTop\": \"Move {name} to the top of the channel order\",\n \"cockpit.tooltip.demoteChannel\": \"Move {name} to the end of the channel order\",\n \"cockpit.tooltip.demoteChannelDisabled\": \"Already the last channel\",\n \"cockpit.tooltip.columnCount\": \"{column}: {count} conversations\",\n \"cockpit.overrideActivePerpetual\": \"Custom channel order is active and will not expire until restored manually\",\n \"cockpit.overrideActive\": \"Custom channel order is active and will restore default scheduling in {time}\",\n \"cockpit.restoreDefault\": \"Restore default order\",\n \"cockpit.overrideDuration\": \"Auto-restore when idle\",\n \"cockpit.noChannelsForKind\": \"No channels available for this type\",\n \"cockpit.board.streaming\": \"Streaming\",\n \"cockpit.board.streamingHint\": \"Conversations currently streaming\",\n \"cockpit.board.working\": \"Working\",\n \"cockpit.board.workingHint\": \"Root conversations with active work\",\n \"cockpit.board.subagents\": \"Subagents\",\n \"cockpit.board.subagentsHint\": \"Conversations with subagents\",\n \"cockpit.board.active\": \"Active\",\n \"cockpit.board.activeHint\": \"Active conversations that are not streaming\",\n \"cockpit.board.idle\": \"Idle\",\n \"cockpit.board.idleHint\": \"Idle conversations\",\n \"cockpit.relation.parent\": \"Parent\",\n \"cockpit.relation.parentThread\": \"Parent thread {id}\",\n \"cockpit.relation.children\": \"{count} subagents\",\n \"cockpit.active\": \"Active: {count}\",\n \"cockpit.override\": \"Override: {count}\",\n \"cockpit.mainConversation\": \"Main conversation\",\n \"cockpit.recap\": \"Recap\",\n \"cockpit.detail.requests\": \"Requests\",\n \"cockpit.detail.duration\": \"Duration\",\n \"cockpit.mainRouting\": \"Main routing\",\n \"cockpit.subagentRouting\": \"Subagent routing\",\n \"cockpit.durationDefault\": \"30 min (default)\",\n \"cockpit.duration15min\": \"15 min\",\n \"cockpit.duration1hour\": \"1 hour\",\n \"cockpit.duration2hours\": \"2 hours\",\n \"cockpit.duration4hours\": \"4 hours\",\n \"cockpit.duration8hours\": \"8 hours\",\n \"cockpit.duration12hours\": \"12 hours\",\n \"cockpit.duration24hours\": \"24 hours\",\n \"cockpit.durationNever\": \"Never restore\",\n \"cockpit.subagentFollowMain\": \"Follow main\",\n \"cockpit.subagentOverride\": \"Custom channel\",\n \"cockpit.subagentClearOverride\": \"Clear\",\n \"capability.title\": \"Capability test - {channel}\",\n \"capability.cancel\": \"Cancel Test\",\n \"console.capability.running\": \"Test in progress...\",\n \"capability.noResults\": \"No capability test results yet\",\n \"console.capability.lastResults\": \"Last test results\",\n \"capability.modelsLabel\": \"Models:\",\n \"capability.duration\": \"Duration\",\n \"console.capability.protocolResults\": \"Protocol results\",\n \"console.capability.compatible\": \"Compatible protocols\",\n \"capability.loadingTitle\": \"Testing protocol compatibility...\",\n \"capability.cancelling\": \"Cancelling...\",\n \"capability.cancelled\": \"Cancelled\",\n \"capability.partial\": \"Partially available\",\n \"capability.noCompatibleProtocols\": \"No compatible protocols\",\n \"capability.modelQueued\": \"Queued\",\n \"capability.protocolRunning\": \"Running\",\n \"capability.rpmLabel\": \"Test RPM\",\n \"capability.progressSummary\": \"{done}/{total} models finished\",\n \"capability.supported\": \"Supported\",\n \"capability.unsupported\": \"Unsupported\",\n \"capability.table.protocol\": \"Protocol\",\n \"capability.table.status\": \"Status\",\n \"capability.table.successCount\": \"Success/Total\",\n \"capability.table.latency\": \"Latency\",\n \"capability.table.streaming\": \"Streaming\",\n \"capability.table.actions\": \"Actions\",\n \"capability.copyToTab\": \"Copy to this tab\",\n \"capability.currentTab\": \"Current tab\",\n \"capability.convert\": \"{protocol} convert\",\n \"capability.modelDetailsUnavailable\": \"Model details unavailable\",\n \"capability.tooltipLatency\": \"Latency\",\n \"capability.tooltipStreaming\": \"Streaming\",\n \"capability.modelStatus\": \"Status\",\n \"capability.actualModel\": \"Actual model\",\n \"capability.codexImageGeneration\": \"Codex image tools\",\n \"capability.codexImageGenerationSummary\": \"{supported}/{total} keys supported\",\n \"capability.codexImageGenerationStripped\": \"compatible via tool stripping\",\n \"capability.retryModel\": \"Retry this model\",\n \"capability.testModel\": \"Click to test\",\n \"capability.runModeReusedRunning\": \"Reused running task\",\n \"capability.runModeResumedCancelled\": \"Resumed cancelled task\",\n \"capability.runModeCacheHit\": \"Cached result\",\n \"capability.runModeReusedPreviousResults\": \"Reused previous success\",\n \"capability.lastResults\": \"Last results\",\n \"capability.models\": \"models\",\n \"capability.snapshotUpdated\": \"Updated: {time}\",\n \"channelLogs.title\": \"Channel logs\",\n \"channelLogs.status.pending\": \"Pending\",\n \"channelLogs.status.firstByte\": \"First byte\",\n \"channelLogs.status.streaming\": \"Streaming\",\n \"channelLogs.status.completed\": \"Completed\",\n \"channelLogs.status.failed\": \"Failed\",\n \"channelLogs.status.cancelled\": \"Cancelled\",\n \"channelLogs.sourceCapabilityTest\": \"Capability test\",\n \"channelLogs.copyEntry\": \"Copy this log\",\n \"channelLogs.copiedEntry\": \"Log copied\",\n \"channelLogs.reasoning.original\": \"Original\",\n \"channelLogs.reasoning.actual\": \"Sent\",\n \"channelLogs.retry\": \"Retry\",\n \"channelLogs.duration.connect\": \"Connect\",\n \"channelLogs.duration.firstByte\": \"First byte\",\n \"channelLogs.duration.total\": \"Total duration\",\n \"console.logs.model\": \"Model\",\n \"console.logs.baseUrl\": \"URL\",\n \"console.logs.key\": \"Key\",\n \"channelLogs.status.connecting\": \"Connecting\",\n \"console.logs.duration\": \"Duration\",\n \"console.logs.error\": \"Error\",\n \"console.logs.entries\": \"entries\",\n \"channelLogs.empty\": \"No logs yet\",\n \"toast.channelCopied\": \"Channel copied to {protocol} tab\",\n \"toast.copyFailed\": \"Failed to copy channel: {message}\",\n \"tooltip.circuitBreakerSettings\": \"Tuning Bench\",\n \"chart.close\": \"Close\",\n \"chart.1h\": \"1h\",\n \"chart.6h\": \"6h\",\n \"chart.24h\": \"24h\",\n \"chart.today\": \"Today\",\n \"chart.7d\": \"7d\",\n \"chart.30d\": \"30d\",\n \"chart.90d\": \"90d\",\n \"chart.180d\": \"180d\",\n \"chart.365d\": \"1 Year\",\n \"chart.thisyear\": \"This Year\",\n \"chart.traffic\": \"Traffic\",\n \"chart.tokens\": \"Tokens\",\n \"chart.cost\": \"Cost\",\n \"chart.cache\": \"Cache\",\n \"chart.cacheRw\": \"Cache R/W\",\n \"chart.cacheRead\": \"Cache Read\",\n \"chart.cacheWrite\": \"Cache Write\",\n \"chart.totalRequests\": \"Total requests\",\n \"chart.requests\": \"Requests\",\n \"chart.successRate\": \"Availability\",\n \"chart.inputTokens\": \"Input tokens\",\n \"chart.outputTokens\": \"Output tokens\",\n \"chart.input\": \"input\",\n \"chart.output\": \"output\",\n \"chart.requestUnit\": \"requests\",\n \"chart.tokenUnit\": \"tokens\",\n \"chart.issueCount\": \"issues\",\n \"chart.total\": \"Total\",\n \"chart.totalCost\": \"Total cost\",\n \"chart.collapse\": \"Collapse\",\n \"chart.expandModelStats\": \"Show model statistics\",\n \"chart.expandChart\": \"Show chart\",\n \"chart.noData\": \"No request data\",\n \"chart.noChannelMetrics\": \"No metrics yet for {channel}\",\n \"chart.noModelData\": \"No model statistics\",\n \"chart.noModelRequestsInRange\": \"No model request records in the selected time range\",\n \"chart.noKeyUsageInRange\": \"No key usage data in the selected time range\",\n \"chart.historyLoadFailed\": \"Failed to load history data\",\n \"chart.modelStatsLoadFailed\": \"Failed to load model statistics\",\n \"chart.globalStatsLoadFailed\": \"Failed to load global statistics\",\n \"chart.noRequestsInRange\": \"No request records in the selected time range\",\n \"channelEditor.title.editSubtitle\": \"Modify channel configuration\",\n \"channelEditor.nav.redirect\": \"Model Redirect\",\n \"channelEditor.basic.name.placeholder\": \"e.g. OpenAI Production\",\n \"channelEditor.basic.website.placeholder\": \"https://openai.com\",\n \"channelEditor.auth.duplicateKey\": \"Duplicate API key\",\n \"channelEditor.auth.addNewApiKey.placeholder\": \"Enter new API key\",\n \"channelEditor.mapping.source.label\": \"Source model\",\n \"channelEditor.mapping.source.placeholder\": \"gpt-4o\",\n \"channelEditor.mapping.target.label\": \"Target model\",\n \"channelEditor.mapping.target.placeholder\": \"claude-sonnet-4-6\",\n \"console.form.presets\": \"Quick presets\",\n \"console.form.existingMappings\": \"Configured mappings\",\n \"console.form.mappings\": \"items\",\n \"console.form.toggleVision\": \"Toggle vision route\",\n \"console.form.visionFallback\": \"Vision fallback\",\n \"console.form.addNewMapping\": \"Add model mapping\",\n \"console.form.createMapping\": \"Create mapping\",\n \"channelEditor.transport.proxyUrl.placeholder\": \"socks5://...\",\n \"channelEditor.transport.requestTimeout.placeholder\": \"60\",\n \"channelEditor.transport.responseHeaderTimeout.placeholder\": \"60\",\n \"channelEditor.transport.routePrefix.placeholder\": \"kimi\",\n \"channelEditor.rateLimit.window.hint\": \"Sliding window duration\",\n \"channelEditor.streamTimeout.strategy\": \"Strategy\",\n \"channelEditor.streamTimeout.strategyInherit\": \"Inherit global\",\n \"channelEditor.timeout.title\": \"Timeout strategy\",\n \"channelEditor.timeout.hint\": \"All values are in seconds; disabled controls inherit global settings\",\n \"channelEditor.toast.updated\": \"Channel updated\",\n \"channelEditor.toast.added\": \"Channel added\",\n \"console.form.existingHeaders\": \"Configured headers\",\n \"console.form.addNewHeader\": \"Add header\",\n \"console.form.headerValuePlaceholder\": \"Header value...\",\n \"console.form.headersNote\": \"Notes\",\n \"console.form.headersNoteInject\": \"Custom headers are injected into every upstream request\",\n \"console.form.headersNoteOverride\": \"Default headers can be overridden, including User-Agent and Authorization\",\n \"console.form.headersNoteTemplate\": \"Header names and values support template variables when backend substitution is available\",\n \"capability.fetchingModels\": \"Fetching model list...\",\n \"capability.selectModels\": \"Select models to test\",\n \"capability.selectAll\": \"Select all\",\n \"capability.deselectAll\": \"Deselect all\",\n \"capability.noModelsFound\": \"No models found. Add models manually below.\",\n \"capability.addModelLabel\": \"Add model\",\n \"capability.addModelPlaceholder\": \"e.g. claude-sonnet-4-6\",\n \"capability.notStarted\": \"Not started\",\n \"capability.compatibleProtocols\": \"Compatible protocols\",\n \"capability.modelDetails\": \"Model details\",\n \"capability.attemptedModels\": \"Attempted models: {count}\",\n \"capability.startedAt\": \"Started at\",\n \"capability.testedAt\": \"Finished at\",\n \"capability.success\": \"Success\",\n \"capability.failed\": \"Failed\",\n \"capability.failedTooltip\": \"Test failed\",\n \"capability.channelFallback\": \"Channel #{id}\",\n \"capability.modelRunning\": \"Running\",\n \"capability.modelSuccess\": \"Available\",\n \"capability.modelFailed\": \"Failed\",\n \"capability.modelSkipped\": \"Skipped\",\n \"capability.reasonNotRun\": \"Not executed in this round\",\n \"capability.reasonCancelled\": \"Cancelled before completion\",\n \"capability.reasonTimeout\": \"Timed out\",\n \"capability.noApiKeyError\": \"This channel has no API key configured and cannot run capability tests.\",\n \"capability.genericJobError\": \"Capability test failed: {message}\",\n \"capability.redirectTests\": \"Model Redirect Tests\",\n \"capability.redirectTestsDesc\": \"Cross-protocol model mapping verification\",\n \"addChannel.quickInputPlaceholder\": \"Paste config snippet to auto-detect Base URL and API Key (multi-line supported)\\nhttps://api.example.com\\nhttps://api-backup.example.com\\nsk-ant-api03-xxxxxxxxxxxxxxxx\\nsk-ant-api03-yyyyyyyyyyyyyyyy\",\n \"addChannel.modelListEmptyHint\": \"No model candidates yet. Fill in Base URL and API key, then focus a model input to load /models automatically.\",\n \"addChannel.modelCapabilitiesRowsHint\": \"Model redirect targets are added here automatically with built-in presets when available. You can still add passthrough or unmapped models manually.\",\n \"addChannel.modelCapabilityRedirectTargets\": \"Redirect targets\",\n \"addChannel.modelCapabilitiesConfigured\": \"Configured model capabilities\",\n \"addChannel.modelCapabilityModelLabel\": \"Actual model\",\n \"addChannel.modelCapabilityModelPlaceholder\": \"Select or enter an upstream model\",\n \"addChannel.contextTokensShort\": \"Context\",\n \"addChannel.outputTokensShort\": \"Output\",\n \"addChannel.thinkingModeLabel\": \"Thinking mode\",\n \"addChannel.reasoningEffortsLabel\": \"Reasoning efforts\",\n \"addChannel.modelCapabilityBuiltinMatched\": \"Preset: {pattern}\",\n \"addChannel.modelCapabilitiesRowsInvalid\": \"Check model capability rows. Model names are required; token and price values must be non-negative.\",\n \"addChannel.inputCacheHitPriceLabel\": \"Cache-hit input\",\n \"addChannel.inputCacheMissPriceLabel\": \"Cache-miss input\",\n \"addChannel.outputPriceLabel\": \"Output price\",\n \"addChannel.pricingTitle\": \"Pricing\",\n \"addChannel.pricingCurrencyLabel\": \"Currency\",\n \"addChannel.pricingUnitLabel\": \"Unit\",\n \"addChannel.defaultOutputTokensMeta\": \"Default output {tokens}\",\n \"addChannel.recommendedOutputTokensMeta\": \"Recommended output {tokens}\",\n \"addChannel.modelPricingUnitHint\": \"Per 1M tokens\",\n \"addChannel.embeddingCompatibilityTitle\": \"Embedding compatibility\",\n \"addChannel.embeddingCompatibilityHint\": \"Keys use the actual upstream model name after model redirect. Fallback only stays within the same embeddingSpaceId/actual model, dimensions, and normalization semantics.\",\n \"addChannel.embeddingCompatibilityRedirectTargets\": \"Redirect targets\",\n \"addChannel.embeddingCompatibilityConfigured\": \"Configured embedding spaces\",\n \"addChannel.embeddingCapabilityModelLabel\": \"Actual model\",\n \"addChannel.embeddingCapabilityModelPlaceholder\": \"Select or enter an upstream embedding model\",\n \"addChannel.embeddingSpaceIdLabel\": \"Vector space ID\",\n \"addChannel.embeddingSpaceIdPlaceholder\": \"Empty uses the actual model name\",\n \"addChannel.embeddingDimensionsLabel\": \"Default dimensions\",\n \"addChannel.embeddingSupportedDimensionsLabel\": \"Supported request dimensions\",\n \"addChannel.embeddingSupportedDimensionsPlaceholder\": \"For example 1024, 1536\",\n \"addChannel.embeddingNormalizedLabel\": \"Normalized\",\n \"addChannel.embeddingNormalizedUnknown\": \"Unspecified\",\n \"addChannel.embeddingNormalizedTrue\": \"Normalized\",\n \"addChannel.embeddingNormalizedFalse\": \"Not normalized\",\n \"addChannel.embeddingCapabilitiesRowsInvalid\": \"Check embedding compatibility rows. Actual model is required; dimensions and supported dimensions must be positive integers.\",\n \"channelDiscovery.title\": \"Discover usable config\",\n \"channelDiscovery.hint\": \"Probe models, protocols, redirects, and compatibility switches with the current Base URL and API key.\",\n \"channelDiscovery.button\": \"Discover config\",\n \"channelDiscovery.failed\": \"Config discovery failed\",\n \"channelDiscovery.missingBaseUrl\": \"Enter a Base URL first.\",\n \"channelDiscovery.missingApiKey\": \"Enter an API key first.\",\n \"channelDiscovery.missingServiceType\": \"Choose a service type first.\",\n \"channelDiscovery.recommendedKind\": \"Recommended {kind}\",\n \"channelDiscovery.modelsFound\": \"{count} models\",\n \"channelDiscovery.mapping\": \"Recommended redirects\",\n \"channelDiscovery.reasoning\": \"Recommended reasoning effort\",\n \"channelDiscovery.reasoningNote\": \"Reasoning effort is a role-based default recommendation. Discovery does not test every effort level.\",\n \"channelDiscovery.compat\": \"Compatibility switches\",\n \"channelDiscovery.capabilities\": \"Capability checks\",\n \"channelDiscovery.capabilityToolCalls\": \"Tool calls\",\n \"channelDiscovery.capabilityVision\": \"Vision\",\n \"channelDiscovery.capabilityImageGeneration\": \"Codex image tools\",\n \"channelDiscovery.capabilityThinkingPassback\": \"Thinking passback\",\n \"channelDiscovery.capabilitySupported\": \"supported\",\n \"channelDiscovery.capabilityUnsupported\": \"unsupported\",\n \"channelDiscovery.capabilityRequired\": \"required\",\n \"channelDiscovery.capabilityNotRequired\": \"not required\",\n \"channelDiscovery.apply\": \"Apply recommendation\",\n \"channelDiscovery.applied\": \"Discovery recommendation applied. Review and save.\",\n \"autopilot.quickAdd.alreadyAdded\": \"This Base URL is already configured as \\\"{name}\\\". New keys will be added to the existing channel.\",\n \"autopilot.costPreference.balanced\": \"Balanced\",\n \"autopilot.costPreference.cost_first\": \"Cost First\",\n \"autopilot.costPreference.quality_first\": \"Quality First\",\n \"autopilot.costPreferenceDesc.balanced\": \"Balance between quality and cost.\",\n \"autopilot.costPreferenceDesc.cost_first\": \"Prioritize low-cost channels, tolerating some quality decrease.\",\n \"autopilot.costPreferenceDesc.quality_first\": \"Prioritize high-quality providers, tolerating higher costs.\",\n \"autopilot.mode.active\": \"Active\",\n \"autopilot.mode.assist\": \"Assist\",\n \"autopilot.mode.auto\": \"Auto\",\n \"autopilot.mode.dry_run\": \"Dry Run\",\n \"autopilot.mode.off\": \"Off\",\n \"autopilot.mode.shadow\": \"Shadow\",\n \"autopilot.modeDesc.assist\": \"Assist mode: reorders candidate list but does not change the final selection.\",\n \"autopilot.modeDesc.auto\": \"Fully automatic mode: filters and reorders, takes over scheduling after meeting admission criteria.\",\n \"autopilot.modeDesc.off\": \"Smart routing is completely disabled. No traces are computed or recorded.\",\n \"autopilot.modeDesc.shadow\": \"Only computes and records routing decisions without affecting real scheduling (recommended).\",\n \"autopilot.modePanel.confirmMessage\": \"About to switch to {mode} mode. This mode will affect real scheduling. Do you want to continue?\",\n \"autopilot.readiness.ready\": \"Auto mode meets the rollout gate\",\n \"autopilot.readiness.collecting\": \"Collecting safety samples for auto mode\",\n \"autopilot.readiness.progress\": \"Valid samples {samples}/{requiredSamples}, observation {hours}/{requiredHours} hours\",\n \"autopilot.readiness.successRate\": \"Success\",\n \"autopilot.readiness.fallbackRate\": \"Fallback\",\n \"autopilot.readiness.failOpenRate\": \"Fail-open\",\n \"autopilot.readiness.lastRollback\": \"At {time}, consecutive SLO regression was detected and auto was downgraded to assist.\",\n \"autopilot.readiness.reason.telemetry_unavailable\": \"Routing telemetry is unavailable\",\n \"autopilot.readiness.reason.telemetry_query_failed\": \"Routing telemetry query failed\",\n \"autopilot.readiness.reason.insufficient_samples\": \"Not enough valid samples\",\n \"autopilot.readiness.reason.insufficient_observation_time\": \"Observation time is insufficient\",\n \"autopilot.readiness.reason.success_rate_below_threshold\": \"Success rate is below 95%\",\n \"autopilot.readiness.reason.fallback_rate_above_threshold\": \"Fallback rate is above 10%\",\n \"autopilot.readiness.reason.failopen_rate_above_threshold\": \"Fail-open rate is above 2%\",\n \"autopilot.readiness.reason.insufficient_comparison_samples\": \"Not enough recent and baseline samples\",\n \"autopilot.readiness.reason.recent_success_regression\": \"Recent success rate regressed\",\n \"autopilot.readiness.reason.recent_fallback_regression\": \"Recent fallback rate increased\",\n \"autopilot.readiness.reason.recent_latency_regression\": \"Recent p95 latency regressed\",\n \"autopilot.modePanel.confirmTitle\": \"Confirm Mode Switch\",\n \"autopilot.modePanel.costPreference\": \"Cost Preference\",\n \"autopilot.modePanel.killSwitch\": \"Kill Switch\",\n \"autopilot.modePanel.killSwitchActive\": \"Kill switch is active. Smart routing is forced off. Disable the kill switch first to use other modes.\",\n \"autopilot.modePanel.killSwitchHint\": \"When enabled, forces smart routing off. Takes priority over routing mode selection.\",\n \"autopilot.modePanel.reset\": \"Reset\",\n \"autopilot.modePanel.routingMode\": \"Routing Mode\",\n \"autopilot.modePanel.save\": \"Save Configuration\",\n \"autopilot.modePanel.title\": \"Global Policy\",\n \"autopilot.title\": \"Smart Routing Autopilot\",\n \"autopilot.traceStats.mismatchRate\": \"Mismatch Rate\",\n \"autopilot.traceStats.mismatches\": \"Mismatches\",\n \"autopilot.traceStats.modeDistribution\": \"Distribution by Mode\",\n \"autopilot.traceStats.taskClassDistribution\": \"Distribution by Task Class\",\n \"autopilot.traceStats.title\": \"Routing Trace Statistics\",\n \"autopilot.traceStats.total\": \"Total Traces\",\n \"autopilot.traceTable.candidates\": \"Candidates\",\n \"autopilot.traceTable.col.kind\": \"Kind\",\n \"autopilot.traceTable.col.match\": \"Match\",\n \"autopilot.traceTable.col.mode\": \"Mode\",\n \"autopilot.traceTable.col.outcome\": \"Outcome\",\n \"autopilot.traceTable.col.model\": \"Model\",\n \"autopilot.traceTable.col.shadowVsActual\": \"Shadow vs Actual\",\n \"autopilot.traceTable.col.taskClass\": \"Task Class\",\n \"autopilot.traceTable.col.time\": \"Time\",\n \"autopilot.traceTable.empty\": \"No trace records yet\",\n \"autopilot.traceTable.mismatchOnly\": \"Mismatches Only\",\n \"autopilot.traceTable.no\": \"No\",\n \"autopilot.traceTable.sortReasons\": \"Sort Reasons\",\n \"autopilot.traceTable.title\": \"Routing Decision Traces\",\n \"autopilot.traceTable.yes\": \"Yes\",\n \"healthCenter.col.channel\": \"Channel\",\n \"healthCenter.col.endpoints\": \"Endpoints\",\n \"healthCenter.col.expand\": \"Details\",\n \"healthCenter.col.kind\": \"Kind\",\n \"healthCenter.col.status\": \"Status\",\n \"healthCenter.col.successRate\": \"Success Rate\",\n \"healthCenter.detail.baseUrl\": \"Base URL\",\n \"healthCenter.detail.confidence\": \"Confidence\",\n \"healthCenter.detail.consecutiveFail\": \"Consecutive Failures\",\n \"healthCenter.detail.evidence\": \"Evidence\",\n \"healthCenter.detail.keyHash\": \"Key\",\n \"healthCenter.detail.lastSuccess\": \"Last Success\",\n \"healthCenter.detail.p95\": \"P95 Latency\",\n \"healthCenter.detail.firstByteP95\": \"P95 First Byte\",\n \"healthCenter.detail.qualityTier\": \"Quality\",\n \"healthCenter.detail.speedTier\": \"Speed\",\n \"healthCenter.detail.stabilityTier\": \"Stability\",\n \"healthCenter.detail.successRate15m\": \"Success Rate (15m)\",\n \"healthCenter.detail.successRate1h\": \"Success Rate (1h)\",\n \"healthCenter.detail.suggestedAction\": \"Suggested Action\",\n \"healthCenter.detail.updatedAt\": \"Updated At\",\n \"healthCenter.kind.chat\": \"Chat\",\n \"healthCenter.kind.gemini\": \"Gemini\",\n \"healthCenter.kind.images\": \"Images\",\n \"healthCenter.kind.messages\": \"Messages\",\n \"healthCenter.kind.responses\": \"Responses\",\n \"healthCenter.kind.vectors\": \"Vectors\",\n \"healthCenter.noChannels\": \"No channel health data yet\",\n \"healthCenter.state.dead\": \"Dead\",\n \"healthCenter.state.degraded\": \"Degraded\",\n \"healthCenter.state.healthy\": \"Healthy\",\n \"healthCenter.state.limited\": \"Limited\",\n \"healthCenter.state.misconfigured\": \"Misconfigured\",\n \"healthCenter.state.unknown\": \"Unknown\",\n \"healthCenter.title\": \"Channel Health Center\",\n \"healthCenter.totalChannels\": \"Total Channels\",\n \"healthCenter.totalEndpoints\": \"Total Endpoints\",\n \"nav.health\": \"Health Center\",\n \"nav.healthDesc\": \"Channel health overview\",\n \"nav.autopilot\": \"Autopilot\",\n \"nav.autopilotDesc\": \"Smart routing autopilot policy\",\n \"tab.healthTitle\": \"Channel Health Center\",\n \"tab.autopilotTitle\": \"Smart Routing Autopilot\",\n \"channelHealth.stateLabel\": \"Health\",\n \"channelHealth.endpointsHealthy\": \"endpoints healthy\",\n \"channelHealth.avgSuccessRate\": \"Avg success rate\",\n \"channelHealth.inconsistentWarning\": \"{count} endpoint(s) inconsistent\",\n \"healthCenter.changelog.title\": \"Recent Changes\",\n \"healthCenter.changelog.empty\": \"No changes yet\",\n \"healthCenter.changelog.live\": \"Live\",\n \"healthCenter.changelog.connecting\": \"Connecting…\",\n \"healthCenter.changelog.disconnected\": \"Live updates disconnected, reconnecting…\",\n \"healthCenter.changelog.type.healthChanged\": \"Health state changed\",\n \"healthCenter.changelog.type.profileUpdated\": \"Profile updated\",\n \"healthCenter.changelog.type.discoveryCompleted\": \"Auto-discovery completed\",\n \"healthCenter.changelog.type.autoMappingApplied\": \"Auto model mapping applied\",\n \"channelHealth.originOfficial\": \"Official API\",\n \"channelHealth.originRelay\": \"Relay\",\n \"channelHealth.originCommunity\": \"Community\",\n \"channelHealth.originUnknown\": \"Unknown origin\",\n \"channelHealth.poolFree\": \"Free pool\",\n \"channelHealth.poolTemp\": \"Temp pool\",\n \"cockpitOverview.title\": \"Operations Cockpit\",\n \"cockpitOverview.health\": \"Health Overview\",\n \"cockpitOverview.channels\": \"Channels\",\n \"cockpitOverview.endpoints\": \"Endpoints\",\n \"cockpitOverview.subscriptions\": \"Subscriptions\",\n \"cockpitOverview.totalSubscriptions\": \"Total Subscriptions\",\n \"cockpitOverview.manualIntents\": \"Manual Intents\",\n \"cockpitOverview.activeIntents\": \"Active Intents\",\n \"cockpitOverview.totalIntents\": \"Total Intents\",\n \"cockpitOverview.todoList\": \"To-Do Items\",\n \"cockpitOverview.empty\": \"No data yet. Please configure channels and subscriptions first.\",\n \"cockpitOverview.recommendations\": \"Channel Recommendations\",\n \"cockpitOverview.noRecommendations\": \"No recommendations yet. Suggestions will appear once usage data accumulates.\",\n \"cockpitOverview.usageCount\": \"{count} recent requests\",\n \"cockpitOverview.currentChannel\": \"Current channel\",\n \"cockpitOverview.recommendedChannel\": \"Try instead\",\n \"cockpitOverview.scoreDelta\": \"Score delta +{delta}\",\n \"cockpitOverview.activeTrials\": \"Active Trial Intents\",\n \"cockpitOverview.noActiveTrials\": \"No active trial intents. Create one via the Trial action in the channel center.\",\n \"cockpitOverview.trialExpired\": \"Expired\",\n \"cockpitOverview.trialStats\": \"Hits {hit} · Success {rate} · Fallback {fallback}\",\n \"cockpitOverview.trialStatsLabel\": \"Trial Stats\",\n \"cockpitOverview.remaining\": \"Remaining\",\n \"cockpitOverview.status\": \"Status\",\n \"manualIntent.field.intentType\": \"Intent type\",\n \"manualIntent.field.channelUid\": \"Channel UID (optional)\",\n \"manualIntent.field.model\": \"Model (optional)\",\n \"manualIntent.intentType.model_trial\": \"Model trial\",\n \"manualIntent.intentType.channel_trial\": \"Channel trial\",\n \"manualIntent.intentType.endpoint_trial\": \"Endpoint trial\",\n \"manualIntent.intentType.session_pin\": \"Session pin\",\n \"manualIntent.status.active\": \"Active\",\n \"manualIntent.status.expired\": \"Expired\",\n \"manualIntent.status.exhausted\": \"Exhausted\",\n \"manualIntent.status.disabled\": \"Disabled\",\n \"manualIntent.actions.end\": \"End\",\n \"manualIntent.field.channelKind\": \"Channel kind\",\n \"manualIntent.field.name\": \"Name (optional)\",\n \"manualIntent.field.metricsKey\": \"Endpoint metrics key (optional)\",\n \"manualIntent.field.mappedModel\": \"Mapped model (optional)\",\n \"manualIntent.field.sessionId\": \"Session ID\",\n \"manualIntent.field.agentRoles\": \"Agent roles\",\n \"manualIntent.field.taskClasses\": \"Task classes\",\n \"manualIntent.field.trafficPercent\": \"Traffic percent\",\n \"manualIntent.field.ttlMinutes\": \"TTL (minutes)\",\n \"manualIntent.field.maxRequests\": \"Max requests\",\n \"manualIntent.field.maxEstimatedCost\": \"Max estimated cost ($)\",\n \"manualIntent.field.fallbackOnFailure\": \"Fallback on failure\",\n \"manualIntent.field.requireHardConstraints\": \"Require hard constraints\",\n \"manualIntent.field.reason\": \"Reason\",\n \"manualIntent.agentRole.main\": \"Main agent\",\n \"manualIntent.agentRole.subagent\": \"Subagent\",\n \"manualIntent.taskClass.supervisor\": \"Supervisor\",\n \"manualIntent.taskClass.worker\": \"Worker\",\n \"manualIntent.taskClass.lightweight\": \"Lightweight\",\n \"manualIntent.taskClass.vision\": \"Vision\",\n \"manualIntent.taskClass.long_context\": \"Long context\",\n \"manualIntent.taskClass.image_generation\": \"Image generation\",\n \"manualIntent.taskClass.embedding\": \"Embedding\",\n \"manualIntent.validation.channelKindRequired\": \"Channel kind is required\",\n \"manualIntent.validation.sessionIdRequired\": \"Session pin requires a session ID\",\n \"manualIntent.actions.create\": \"Create trial\",\n \"manualIntent.actions.cancel\": \"Cancel\",\n \"manualIntent.error.createFailed\": \"Failed to create trial intent\",\n \"subscription.newApi.maxGroupMultiplier\": \"Maximum group multiplier\",\n \"subscription.newApi.maxGroupMultiplierHint\": \"Provision every group with multiplier at or below {limit}.\",\n \"subscription.newApi.invalidMaxGroupMultiplier\": \"Enter a finite number greater than or equal to zero.\",\n \"subscription.newApi.excludedGroups\": \"{count} group(s) exceed the {limit} limit and will be excluded.\",\n \"subscription.newApi.groupFetchError\": \"Could not fetch group multipliers:\",\n \"subscription.newApi.noEligibleGroups\": \"No eligible groups at or below {limit}.\",\n \"subscription.newApi.eligibleGroups\": \"{count} eligible group(s):\",\n \"subscription.management.title\": \"Subscription management\",\n \"subscription.management.description\": \"Review balances, billing terms, refresh results, and per-key multiplier status.\",\n \"subscription.management.zeroBalance\": \"Zero balance\",\n \"subscription.management.refresh\": \"Refresh\",\n \"subscription.management.refreshed\": \"Subscription refreshed.\",\n \"subscription.management.empty\": \"No managed subscriptions yet.\",\n \"billingTerms.unconfigured\": \"Billing terms not configured\",\n \"billingTerms.preview\": \"Pay {payment} {paymentUnit} and receive {credit} {creditUnit}\",\n \"billingTerms.edit\": \"Edit billing terms\",\n \"billingTerms.paymentAmount\": \"Payment amount\",\n \"billingTerms.paymentUnit\": \"Payment unit\",\n \"billingTerms.creditAmount\": \"Credit amount\",\n \"billingTerms.creditUnit\": \"Credit unit\",\n \"billingTerms.invalidAmount\": \"Amounts must be finite numbers greater than zero.\",\n \"billingTerms.save\": \"Save billing terms\",\n \"billingTerms.saved\": \"Billing terms saved.\",\n \"exchangeRates.title\": \"Exchange rates\",\n \"exchangeRates.description\": \"Replace the complete quote graph and inspect derived USD unit prices.\",\n \"exchangeRates.add\": \"Add quote\",\n \"exchangeRates.sourceAmount\": \"Source amount\",\n \"exchangeRates.sourceUnit\": \"Source unit\",\n \"exchangeRates.targetAmount\": \"Target amount\",\n \"exchangeRates.targetUnit\": \"Target unit\",\n \"exchangeRates.empty\": \"No quotes configured. Saving an empty list clears all rates.\",\n \"exchangeRates.snapshot\": \"Snapshot v{version}\",\n \"exchangeRates.invalidQuote\": \"Each quote requires positive finite amounts and non-empty units.\",\n \"exchangeRates.replace\": \"Replace all rates\",\n \"exchangeRates.saved\": \"Exchange rates saved.\",\n \"multiplier.group\": \"Group multiplier\",\n \"multiplier.max\": \"Maximum multiplier\",\n \"multiplier.groupName\": \"Group\",\n \"multiplier.statusLabel\": \"Status\",\n \"multiplier.reason\": \"Reason\",\n \"multiplier.ttl\": \"Expires\",\n \"multiplier.eligible\": \"Eligible\",\n \"multiplier.ineligible\": \"Ineligible\",\n \"multiplier.invalid\": \"Multipliers must be finite numbers greater than or equal to zero.\",\n \"multiplier.save\": \"Save multiplier\",\n \"multiplier.status.manual\": \"Manual\",\n \"multiplier.status.fresh\": \"Fresh\",\n \"multiplier.status.stale\": \"Stale\",\n \"multiplier.status.over_limit\": \"Over limit\",\n \"multiplier.status.sync_error\": \"Sync error\",\n \"multiplier.status.relink_required\": \"Relink required\",\n \"multiplier.status.remote_group_missing\": \"Remote group missing\"\n}\n"}}, "frontend/src/utils/provider-links.ts": {"ebe33699466c4c93f605d8b16e5eb0567c127bd5": {"t": 1785596136.492377, "n": 2017, "h": "1d4bf51d69048187", "c": "// 服务商官方控制台与推广链接映射。\n// 与桌面端 desktop/frontend/src/lib/external-link.ts 保持一致,用于订阅中心展示卡片的外链入口。\n\n/** 官方控制台链接(查看用量 / 余额 / 密钥) */\nexport const providerConsoleLinks: Record = {\n deepseek: 'https://platform.deepseek.com/usage',\n mimo: 'https://platform.xiaomimimo.com/console/balance',\n compshare: 'https://console.compshare.cn/light-gpu/model-subscription',\n runapi: 'https://runapi.co/console',\n 'tencent-lkeap': 'https://console.cloud.tencent.com/lkeap/token-plan',\n kimi: 'https://platform.moonshot.cn/console/account',\n volcengine:\n 'https://console.volcengine.com/ark/region:cn-beijing/subscription/coding-plan?projectName=default',\n qianfan: 'https://console.bce.baidu.com/qianfan/resource/subscribe',\n originrouter: 'https://easytransnote.com/ai/console/#key',\n glm: 'https://open.bigmodel.cn/coding-plan/personal/overview',\n sensenova: 'https://platform.sensenova.cn/console',\n minimax: 'https://platform.minimaxi.com/user-center/payment/balance',\n dashscope: 'https://bailian.console.aliyun.com/cn-beijing?tab=model#/api-key',\n 'opencode-zen': 'https://opencode.ai/',\n openrouter: 'https://openrouter.ai/keys',\n modelscope: 'https://modelscope.cn/my/myaccesstoken',\n xfyun: 'https://console.xfyun.cn/',\n}\n\n/** 推广 / 注册链接(带 aff 溯源,标记赞助商渠道) */\nexport const providerPromotionLinks: Record = {\n compshare: 'https://www.compshare.cn/?ytag=GPU_YY_git_ccx',\n runapi: 'https://runapi.co/register?aff=CqQO',\n volcengine:\n 'https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccx',\n}\n\nexport function openExternal(url: string) {\n if (typeof window !== 'undefined') window.open(url, '_blank', 'noopener,noreferrer')\n}\n\nexport function openProviderConsole(providerId: string) {\n const url = providerConsoleLinks[providerId]\n if (url) openExternal(url)\n}\n\nexport function openProviderPromotion(providerId: string) {\n const url = providerPromotionLinks[providerId]\n if (url) openExternal(url)\n}\n"}}, "README.zh-CN.md": {"ebe33699466c4c93f605d8b16e5eb0567c127bd5": {"t": 1785596136.597294, "n": 7679, "h": "a8d8da93d8ad9fb4", "c": "# Claude / OpenAI Chat / OpenAI Images / OpenAI Embeddings / Codex Responses / Gemini API Proxy - CCX\n\n[English](README.md) | 简体中文\n\n[![GitHub release](https://img.shields.io/github/v/release/BenedictKing/ccx)](https://github.com/BenedictKing/ccx/releases/latest)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)\n\nCCX 是一个高性能的 AI API 代理与协议转换网关,支持 Claude、OpenAI Chat、OpenAI Images、OpenAI Embeddings、Codex Responses 与 Gemini。它提供统一入口、内置 Web 管理界面、渠道编排、故障转移、多密钥管理和模型路由能力。\n\n## 功能特性\n\n- 后端与前端一体化架构,单端口部署\n- 双密钥认证:`PROXY_ACCESS_KEY` 与可选 `ADMIN_ACCESS_KEY`\n- 内置 Web 管理面板,支持渠道管理、测试、日志和监控\n- 同时支持 Claude Messages、OpenAI Chat Completions、OpenAI Images、OpenAI Embeddings、Codex Responses、Gemini\n- 智能调度:优先级、促销期、健康检查、故障转移与熔断恢复\n- 上下文路由:根据实际模型上下文窗口和最大输出能力过滤候选渠道\n- 每个渠道支持多 API Key 轮换、代理、自定义请求头、模型白名单和路由前缀\n- Responses API 支持多轮会话跟踪\n- 前端构建产物嵌入后端,便于单二进制部署\n\n## 赞助商\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
\"火山引擎\"感谢 字节火山引擎 赞助本项目!【专属活动优惠】19元Tokens包!享字节自研豆包模型+满血版开源 SOTA模型,覆盖文本、VLM、图像生成,全模态一站配齐:Seed-2.1、Seedream-5.0、GLM-5.2、DeepSeek等。不止编程、更能解决 Agent 复杂长程任务 -->注册即领2500万Tokens,立即前往
\"优云智算\"感谢 优云智算 赞助了本项目!优云智算是 UCloud 旗下 AI 云平台,主打包月、按次的高性价比国模 Agent Plan 套餐,低至 49 元/月起。同时提供官转稳定海外模型,支持接入 Claude Code、Codex 及 API 调用,支持企业高并发、7*24 技术支持、自助开票。通过此链接注册的用户,可得免费 5 元平台体验金!
\"RunAPI\"感谢 RunAPI 赞助了本项目!RunAPI 是高效稳定的API OpenRouter平替平台,一个 API Key 即可访问 OpenAI、Claude、Gemini、DeepSeek、Grok 等 150+ 主流模型,低至 1 折,极其稳定,可以无缝兼容 Claude Code、OpenClaw 等工具。RunAPI 为 CCX用户提供专属福利:注册联系管理员即可领取¥7的免费额度
\n\n## 界面预览\n\n### 渠道编排\n\n可视化渠道管理,支持拖拽调整优先级,实时查看渠道健康状态和调度统计。\n\n![渠道编排](docs/screenshots/channel-orchestration.png)\n\n### 添加渠道\n\n支持多种上游服务类型,灵活配置 API 密钥、模型映射和请求参数。\n\n\"添加渠道\"\n\n### 流量统计\n\n实时监控各渠道的请求流量、成功率和响应延迟。\n\n![流量统计](docs/screenshots/traffic-stats.png)\n\n## 架构概览\n\nCCX 对外提供一个统一后端入口:\n\n```text\n客户端 -> backend :3000 ->\n |- / -> Web 管理界面\n |- /api/* -> 管理 API\n |- /v1/messages -> Claude Messages 代理\n |- /v1/chat/completions -> OpenAI Chat 代理\n |- /v1/responses -> Codex Responses 代理\n |- /v1/images/{...} -> OpenAI Images 代理\n |- /v1/embeddings -> OpenAI Embeddings 代理\n |- /v1/models -> Models API\n `- /v1beta/models/* -> Gemini 代理\n```\n\n当前 Images 入口包括:\n- `POST /v1/images/generations`\n- `POST /v1/images/edits`\n- `POST /v1/images/variations`\n\n详细设计请参考 [ARCHITECTURE.md](docs/guide/architecture.md)。\n\n## 快速开始\n\n### 方式零:CCX Desktop\n\nCCX Desktop 提供原生桌面体验,通过 GUI 管理渠道、密钥和客户端配置。\n\n| 平台 | 安装方式 | 说明 |\n|------|---------|------|\n| **Windows** | 在 [Microsoft Store](https://apps.microsoft.com/detail/ccx-desktop) 搜索 **CCX Desktop** | 推荐方式。自动更新,无需手动签名。也可从 [GitHub Releases](https://github.com/BenedictKing/ccx/releases/latest) 下载 `setup.exe`。 |\n| **macOS** | `brew tap BenedictKing/ccx && brew install --cask ccx-desktop` | 或从 [GitHub Releases](https://github.com/BenedictKing/ccx/releases/latest) 下载 `.dmg`(支持 arm64/amd64)。 |\n| **Linux** | 从 [GitHub Releases](https://github.com/BenedictKing/ccx/releases/latest) 下载 `.AppImage` | 设置可执行权限后运行。 |\n\n详细安装和配置说明请参考 [CCX Desktop 用户教程](docs/guide/desktop)。\n\n### 方式一:直接运行二进制\n\n1. 从 [Releases](https://github.com/BenedictKing/ccx/releases/latest) 下载最新可执行文件。\n2. 在可执行文件同目录创建 `.env`:\n\n```bash\nPROXY_ACCESS_KEY=your-proxy-access-key\nPORT=3688\nENABLE_WEB_UI=true\nAPP_UI_LANGUAGE=zh-CN\n```\n\n3. 启动后访问 `http://localhost:3000`\n\nWindows 下如果客户端运行在 cmd、PowerShell、WSL 或 Docker 中,且 `localhost` 无法访问 CCX,建议改用 Windows 主机的局域网 IPv4 地址,例如 `http://192.168.1.23:3000`。`BIND_HOST` 留空时,CCX 默认通过 `:PORT` 监听所有网卡地址;如需仅本机访问,设置 `BIND_HOST=127.0.0.1`。\n\n需要后台运行或开机自启动时,参考 [非 Docker 自启动](docs/service/README.md)。\n\n### 方式二:Docker\n\n```bash\ndocker run -d \\\n --name ccx \\\n -p 3000:3000 \\\n -e PROXY_ACCESS_KEY=your-proxy-access-key \\\n -e APP_UI_LANGUAGE=zh-CN \\\n -v $(pwd)/.config:/app/.config \\\n crpi-i19l8zl0ugidq97v.cn-hangzhou.personal.cr.aliyuncs.com/bene/ccx:latest\n```\n\n使用 Docker Compose 后台运行:\n\n```bash\ndocker compose up -d\n```\n\n启用 Watchtower 自动更新:\n\n```bash\ndocker compose -f docker-compose.yml -f docker-compose.watchtower.yml up -d\n```\n\n首次部署后如需立即拉取最新镜像:\n\n```bash\ndocker compose pull ccx\ndocker compose up -d ccx\n```\n\n### 方式三:源码构建\n\n前置依赖:[Go 1.25+](https://go.dev/dl/)、[Bun](https://bun.sh)、Make(macOS 需先执行 `xcode-select --install`)。\n\n```bash\ngit clone https://github.com/BenedictKing/ccx\ncd ccx\ncp backend-go/.env.example backend-go/.env\nmake install # 安装所有依赖(前端 + Go 模块 + 开发工具)\nmake run\n```\n\n常用命令:\n\n```bash\nmake dev\nmake run\nmake build\nmake frontend-dev\n```\n\n## 主要环境变量\n\n```bash\nPORT=3688\nENV=production\nENABLE_WEB_UI=true\nPROXY_ACCESS_KEY=your-proxy-access-key\nADMIN_ACCESS_KEY=your-admin-secret-key\nAPP_UI_LANGUAGE=zh-CN\nLOG_LEVEL=info\n```\n\n## 主要接口\n\n- Web UI:`GET /`\n- 健康检查:`GET /health`\n- 管理 API:`/api/*`\n- Claude Messages:`POST /v1/messages`\n- OpenAI Chat:`POST /v1/chat/completions`\n- Codex Responses:`POST /v1/responses`\n- OpenAI Images:`POST /v1/images/generations`、`POST /v1/images/edits`、`POST /v1/images/variations`\n- OpenAI Embeddings:`POST /v1/embeddings`\n- Gemini:`POST /v1beta/models/{model}:generateContent`\n- 模型列表:`GET /v1/models`\n\n## 开发\n\n推荐本地开发方式:\n\n```bash\nmake dev\n```\n\n仅前端:\n\n```bash\ncd \"frontend\"\nbun install\nbun run dev\n```\n\n仅后端:\n\n```bash\ncd \"backend-go\"\nmake dev\n```\n\n## 相关文档\n\n- [CCX Desktop 用户教程](docs/guide/desktop)\n- [客户端接入总览](docs/guide/clients)\n- [README.md](README.md)\n- [backend-go/README.md](backend-go/README.md)\n- [ARCHITECTURE.md](docs/guide/architecture.md)\n- [DEVELOPMENT.md](docs/guide/development.md)\n- [ENVIRONMENT.md](docs/guide/environment.md)\n- [docs/service/README.md](docs/service/README.md) - 非 Docker 自启动\n- [RELEASE.md](docs/guide/release.md)\n\n## 社区交流\n\n欢迎加入 QQ 群交流讨论:**642217364**\n\n\"QQ群二维码\"\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/chart?repos=BenedictKing/ccx&type=date&legend=top-left&sealed_token=fSL3s-l1Dxr6NrYaxkzzCfeauAKUtRuCg2Npgs_c21isq1szm991sEuNF05_2DoNV0dYEw)](https://www.star-history.com/?repos=BenedictKing%2Fccx&type=date&legend=top-left)\n\n## 致谢\n\n\n \n \n \n \n
\n \"SignPath\n \n Windows 代码签名由 SignPath.io 免费提供,证书由 SignPath Foundation 签发。\n
\n\n## 许可证\n\nMIT\n"}}, "desktop/frontend/src/utils/quick-input-parser.ts": {"ebe33699466c4c93f605d8b16e5eb0567c127bd5": {"t": 1785674144.0580962, "n": 12239, "h": "548ffb5a6f04bb4e", "c": "import { deduplicateEquivalentBaseUrls, type ServiceType } from './base-url-semantics'\n\n/**\n * 快速添加渠道 - 输入解析工具\n *\n * 用于识别 API Key 和 URL 格式\n */\n\n/**\n * 检测字符串是否看起来像配置键名\n * - 全大写 + 下划线分隔的多段单词:API_TIMEOUT_MS, ANTHROPIC_BASE_URL\n * - 全小写常见配置字段名:api_key, base_url, url, name, token, auth, secret, endpoint, model, type\n * (来自用户粘贴 yaml/markdown 配置时的字段名残留,而不是真实密钥)\n */\nconst looksLikeConfigKey = (token: string): boolean => {\n // 1) 全大写字母 + 下划线,且由多个单词组成(至少包含一个下划线分隔的段)\n if (/^[A-Z][A-Z0-9]*(_[A-Z][A-Z0-9]*)+$/.test(token)) {\n return true\n }\n\n // 2) 全小写、常见配置字段名(精确白名单避免误伤短前缀密钥如 hf_short / r8_abc)\n const LOWERCASE_CONFIG_KEYS = new Set([\n 'api_key',\n 'apikey',\n 'api_secret',\n 'access_key',\n 'access_token',\n 'auth_token',\n 'auth_key',\n 'secret_key',\n 'secret',\n 'token',\n 'auth',\n 'base_url',\n 'baseurl',\n 'base',\n 'url',\n 'endpoint',\n 'host',\n 'name',\n 'model',\n 'model_name',\n 'type',\n 'service_type',\n 'provider',\n 'env',\n ])\n if (LOWERCASE_CONFIG_KEYS.has(token.toLowerCase())) {\n return true\n }\n\n return false\n}\n\n/**\n * 各平台 API Key 格式的专用正则匹配\n *\n * 国际主流:\n * - OpenAI Legacy: sk-[a-zA-Z0-9]{48}\n * - OpenAI Project: sk-proj-[a-zA-Z0-9-]{100,}\n * - Anthropic: sk-ant-api03-[a-zA-Z0-9-]{80,}\n * - Google Gemini: AIza[0-9A-Za-z-_]{35}\n * - Azure OpenAI: 32位十六进制\n *\n * 新兴生态:\n * - Hugging Face: hf_[a-zA-Z0-9]{34}\n * - Groq: gsk_[a-zA-Z0-9]{52}\n * - Perplexity: pplx-[a-zA-Z0-9]{40,}\n * - Replicate: r8_[a-zA-Z0-9]+\n * - OpenRouter: sk-or-v1-[a-zA-Z0-9]{50,}\n *\n * 国内平台:\n * - DeepSeek/Moonshot/01.AI/SiliconFlow: sk-[a-zA-Z0-9]{48} (兼容 OpenAI)\n * - 智谱 AI: [a-z0-9]{32}\\.[a-z0-9]+ (id.secret 格式)\n * - 火山引擎 Ark: UUID 格式\n * - 火山引擎 IAM: AK 开头\n */\nconst PLATFORM_KEY_PATTERNS: RegExp[] = [\n // OpenAI Project Key (新格式,最长,优先匹配)\n /^sk-proj-[a-zA-Z0-9_-]{50,}$/,\n // Anthropic Claude\n /^sk-ant-api03-[a-zA-Z0-9_-]{50,}$/,\n // OpenRouter (混合大小写字母数字)\n /^sk-or-v1-[a-zA-Z0-9]{50,}$/,\n // OpenAI Legacy / DeepSeek / Moonshot / 01.AI / SiliconFlow\n /^sk-[a-zA-Z0-9]{20,}$/,\n // Google Gemini/PaLM (通常 39 字符,允许一定范围)\n /^AIza[0-9A-Za-z_-]{30,}$/,\n // Hugging Face\n /^hf_[a-zA-Z0-9]{30,}$/,\n // Groq\n /^gsk_[a-zA-Z0-9]{40,}$/,\n // Perplexity\n /^pplx-[a-zA-Z0-9]{40,}$/,\n // Replicate\n /^r8_[a-zA-Z0-9]{20,}$/,\n // 智谱 AI (id.secret 格式)\n /^[a-zA-Z0-9]{20,}\\.[a-zA-Z0-9]{10,}$/,\n // 火山引擎 Ark (UUID 格式)\n /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,\n // 火山引擎 IAM AK\n /^AK[A-Z]{2,4}[a-zA-Z0-9]{20,}$/\n]\n\n/**\n * 检测字符串是否为有效的 API Key\n *\n * 支持的格式:\n * 1. 平台特定格式(优先匹配,准确度最高)\n * 2. 通用前缀格式:xx-xxx 或 xx_xxx(如 sk-xxx, ut_xxx, api-xxx)\n * 3. JWT 格式:eyJ 开头,包含两个点分隔的 base64 段\n * 4. 长随机字符串:≥32 字符的字母数字串(必须包含字母和数字)\n * 5. 宽松兜底:常见前缀 + 任意后缀(当以上都不匹配时)\n *\n * 排除的格式:\n * - 配置键名:全大写 + 下划线分隔(如 API_TIMEOUT_MS)\n */\nexport const isValidApiKey = (token: string): boolean => {\n // 首先排除配置键名格式\n if (looksLikeConfigKey(token)) {\n return false\n }\n\n // 1. 平台特定格式匹配(最准确)\n for (const pattern of PLATFORM_KEY_PATTERNS) {\n if (pattern.test(token)) {\n return true\n }\n }\n\n // 2. 通用前缀格式(前缀 2-6 字母 + 连字符/下划线 + 至少 10 字符后缀)\n // 后缀必须包含数字或混合大小写(随机特征)\n if (/^[a-zA-Z]{2,6}[-_][a-zA-Z0-9_-]{10,}$/.test(token)) {\n const suffix = token.replace(/^[a-zA-Z]{2,6}[-_]/, '')\n const hasDigit = /\\d/.test(suffix)\n const hasMixedCase = /[a-z]/.test(suffix) && /[A-Z]/.test(suffix)\n if (hasDigit || hasMixedCase) {\n return true\n }\n }\n\n // 3. JWT 格式 (eyJ 开头,包含两个点分隔的 base64 段,总长度 >= 20)\n if (/^eyJ[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]+\\./.test(token) && token.length >= 20) {\n return true\n }\n\n // 4. 长随机字符串(≥32 字符,必须同时包含字母和数字)\n if (token.length >= 32 && /^[a-zA-Z0-9_-]+$/.test(token) && /[a-zA-Z]/.test(token) && /\\d/.test(token)) {\n return true\n }\n\n // 5. 宽松兜底:常见 API Key 前缀 + 任意后缀(至少 1 个字符)\n // 当以上严格规则都不匹配时,放松标准识别常见格式\n // 支持:sk-xxx, api-xxx, key-xxx, ut_xxx, hf_xxx, gsk_xxx 等\n if (/^(sk|api|key|ut|hf|gsk|cr|ms|r8|pplx)[-_].+$/i.test(token)) {\n return true\n }\n\n return false\n}\n\n/**\n * 检测字符串是否为有效的 URL\n *\n * 要求:\n * - 必须以 http:// 或 https:// 开头\n * - 必须包含有效域名(域名段不能以横线开头或结尾)\n * - 支持末尾 # 标记(用于跳过自动添加 /v1)\n */\nexport const isValidUrl = (token: string): boolean => {\n // 域名段不能以横线开头或结尾,支持末尾 # 或 / 或直接结束\n return /^https?:\\/\\/[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*(:\\d+)?(\\/|#|$)/i.test(\n token\n )\n}\n\n/**\n * 从输入中提取所有 token\n * 按空白/逗号/分号/中英文冒号/换行/引号(中英文)/等号/%20 分割\n * 注意:URL 里的 `://` 冒号必须保留,否则会被误切。\n */\nconst extractTokens = (input: string): string[] => {\n // 用占位符保护整段 URL 中的所有冒号(含 `://` 协议冒号与 `host:port` 端口冒号),\n // 避免被英文冒号分隔符切碎。匹配范围:从 `http(s)://` 到下一个空白/引号/中文标点/逗号/分号/换行。\n const COLON_PLACEHOLDER = '__URLCOLON__'\n const protectedInput = input\n .replace(/%20/g, ' ')\n .replace(/https?:\\/\\/[^\\s,;,;:=\"“”'‘’\\n]+/gi, m => m.replace(/:/g, COLON_PLACEHOLDER))\n\n return protectedInput\n .split(/[\\n\\s,;,;::=\"“”'‘’]+/)\n .map(t => t.split(COLON_PLACEHOLDER).join(':'))\n .filter(t => t.length > 0)\n}\n\n/**\n * 根据 URL 路径检测服务类型,并返回清理后的 baseUrl\n * /messages → claude, /chat/completions or /embeddings → openai, /responses → responses\n */\nconst detectServiceTypeAndCleanUrl = (\n url: string\n): { serviceType: 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null; cleanedUrl: string } => {\n try {\n const cleanUrl = url.replace(/#$/, '')\n const parsed = new URL(cleanUrl)\n const path = parsed.pathname.toLowerCase()\n\n const endpointRules: Array<{ pattern: RegExp; serviceType: 'openai' | 'gemini' | 'claude' | 'responses' }> = [\n { pattern: /\\/v\\d+[a-z]*\\/messages(?:\\/|$)/, serviceType: 'claude' },\n { pattern: /\\/messages(?:\\/|$)/, serviceType: 'claude' },\n { pattern: /\\/v\\d+[a-z]*\\/embeddings(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/embeddings(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/v\\d+[a-z]*\\/chat\\/completions(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/chat\\/completions(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/v\\d+[a-z]*\\/responses(?:\\/|$)/, serviceType: 'responses' },\n { pattern: /\\/responses(?:\\/|$)/, serviceType: 'responses' },\n { pattern: /\\/v\\d+[a-z]*\\/models\\/[^/]+:generatecontent(?:\\/|$)/, serviceType: 'gemini' },\n { pattern: /\\/models\\/[^/]+:generatecontent(?:\\/|$)/, serviceType: 'gemini' },\n { pattern: /\\/generatecontent(?:\\/|$)/, serviceType: 'gemini' },\n ]\n for (const rule of endpointRules) {\n const match = path.match(rule.pattern)\n if (!match || match.index === undefined) continue\n // 移除协议端点路径,保留 /v1 或 /v1beta 等版本前缀。\n parsed.pathname = path.slice(0, match.index) || '/'\n let result = parsed.toString().replace(/\\/$/, '')\n if (url.endsWith('#')) result += '#'\n return { serviceType: rule.serviceType, cleanedUrl: result }\n }\n\n if (parsed.hostname.toLowerCase() === 'api.githubcopilot.com') {\n return { serviceType: 'copilot', cleanedUrl: parsed.origin }\n }\n const urlKey = `${parsed.origin}${path}`.replace(/\\/$/, '')\n const knownClaudeUrls = new Set([\n 'https://cp.compshare.cn',\n 'https://api.kimi.com/coding',\n 'https://ark.cn-beijing.volces.com/api/coding',\n 'https://openrouter.ai/api',\n 'https://api-inference.modelscope.cn',\n 'https://api.easytransnote.com/coding',\n 'https://maas-coding-api.cn-huabei-1.xf-yun.com/anthropic',\n 'https://maas-api.cn-huabei-1.xf-yun.com/anthropic',\n ])\n const knownOpenAIUrls = new Set([\n 'https://api.deepseek.com/v1',\n 'https://api.xiaomimimo.com/v1',\n 'https://token-plan-cn.xiaomimimo.com/v1',\n 'https://token-plan-sgp.xiaomimimo.com/v1',\n 'https://token-plan-ams.xiaomimimo.com/v1',\n 'https://cp.compshare.cn/v1',\n 'https://api.moonshot.cn/v1',\n 'https://api.kimi.com/coding/v1',\n 'https://open.bigmodel.cn/api/coding/paas/v4',\n 'https://open.bigmodel.cn/api/paas/v4',\n 'https://api.minimax.chat/v1',\n 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n 'https://coding.dashscope.aliyuncs.com/v1',\n 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1',\n 'https://opencode.ai/zen/v1',\n 'https://opencode.ai/zen/go/v1',\n 'https://api.lkeap.cloud.tencent.com/plan/v3',\n 'https://ark.cn-beijing.volces.com/api/coding/v3',\n 'https://qianfan.baidubce.com/v2/coding',\n 'https://openrouter.ai/api/v1',\n 'https://api-inference.modelscope.cn/v1',\n 'https://api.easytransnote.com/coding/v1',\n 'https://maas-coding-api.cn-huabei-1.xf-yun.com/v2',\n 'https://maas-api.cn-huabei-1.xf-yun.com/v2',\n ])\n if (knownClaudeUrls.has(urlKey)) {\n return { serviceType: 'claude', cleanedUrl: cleanUrl }\n }\n if (knownOpenAIUrls.has(urlKey)) {\n return { serviceType: 'openai', cleanedUrl: cleanUrl }\n }\n\n const hintText = `${parsed.hostname.toLowerCase()} ${path}`\n if (/\\b(anthropic|claude)\\b/.test(hintText) || /(^|\\/)(anthropic|claude)(?:\\/|$)/.test(path)) {\n return { serviceType: 'claude', cleanedUrl: url }\n }\n if (/\\bgemini\\b/.test(hintText) || /generativelanguage\\.googleapis\\.com$/i.test(parsed.hostname)) {\n return { serviceType: 'gemini', cleanedUrl: url }\n }\n if (/\\bresponses\\b/.test(hintText)) {\n return { serviceType: 'responses', cleanedUrl: url }\n }\n if (/\\b(openai|chatgpt)\\b/.test(hintText)) {\n return { serviceType: 'openai', cleanedUrl: url }\n }\n\n // 剔除常见第三方面板路径,仅保留 origin 作为 baseUrl\n const dashboardPathPrefixes = [\n '/admin',\n '/console',\n '/dashboard',\n '/keys',\n '/api-keys',\n '/apikeys',\n '/panel',\n '/token',\n '/profile',\n '/usage',\n '/wallet',\n '/log',\n '/pricing'\n ]\n if (dashboardPathPrefixes.some(prefix => path === prefix || path.startsWith(prefix + '/'))) {\n let result = parsed.origin\n if (url.endsWith('#')) result += '#'\n return { serviceType: null, cleanedUrl: result }\n }\n } catch {\n // 忽略解析错误\n }\n return { serviceType: null, cleanedUrl: url }\n}\n\n// 保留导出以兼容可能的外部使用\nexport const detectServiceType = (url: string): 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null => {\n return detectServiceTypeAndCleanUrl(url).serviceType\n}\n\n/** Base URL 最大数量限制 */\nconst MAX_BASE_URLS = 10\n\n/**\n * 对 Base URL 列表去重(等效 URL 仅保留一条;尾部 # 视为不同语义)\n */\nexport function deduplicateBaseUrls(urls: string[], serviceType: ServiceType = ''): string[] {\n return deduplicateEquivalentBaseUrls(urls, serviceType)\n}\n\n/**\n * 解析快速输入内容,提取 URL 和 API Keys\n *\n * 支持的格式:\n * 1. 纯文本:URL 和 API Key 以空白/逗号/分号/等号分隔\n * 2. 引号包裹:从 \"xxx\" 或 'xxx' 中提取内容(支持 JSON 配置格式)\n * 3. 多 Base URL:所有符合 HTTP 链接格式的都作为 baseUrl(最多 10 个)\n */\nexport const parseQuickInput = (\n input: string,\n fallbackServiceType: ServiceType = ''\n): {\n detectedBaseUrl: string\n detectedBaseUrls: string[]\n rawBaseUrls: string[]\n detectedApiKeys: string[]\n detectedServiceType: 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null\n} => {\n const rawUrls: string[] = []\n let detectedServiceType: 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null = null\n const detectedApiKeys: string[] = []\n\n const tokens = extractTokens(input)\n\n for (const token of tokens) {\n if (isValidUrl(token)) {\n const endsWithHash = token.endsWith('#')\n let url = endsWithHash ? token.slice(0, -1) : token\n url = url.replace(/\\/$/, '')\n const fullUrl = endsWithHash ? url + '#' : url\n\n // 检测协议并清理 URL(移除端点路径)\n const { serviceType, cleanedUrl } = detectServiceTypeAndCleanUrl(fullUrl)\n\n rawUrls.push(cleanedUrl)\n // 使用第一个 URL 的服务类型\n if (!detectedServiceType) {\n detectedServiceType = serviceType\n }\n continue\n }\n\n if (isValidApiKey(token) && !detectedApiKeys.includes(token)) {\n detectedApiKeys.push(token)\n }\n }\n\n // 先去重,再限制数量,避免误拒绝等效 URL\n const deduplicatedRawBaseUrls = Array.from(new Set(rawUrls)).slice(0, MAX_BASE_URLS)\n const detectedBaseUrls = deduplicateBaseUrls(deduplicatedRawBaseUrls, detectedServiceType || fallbackServiceType).slice(0, MAX_BASE_URLS)\n\n return {\n detectedBaseUrl: detectedBaseUrls[0] || '',\n detectedBaseUrls,\n rawBaseUrls: deduplicatedRawBaseUrls,\n detectedApiKeys,\n detectedServiceType\n }\n}\n"}, "784041ebc2a680321b17c44db2f89e95b9470dbe": {"t": 1785809840.441236, "n": 12239, "h": "548ffb5a6f04bb4e", "c": "import { deduplicateEquivalentBaseUrls, type ServiceType } from './base-url-semantics'\n\n/**\n * 快速添加渠道 - 输入解析工具\n *\n * 用于识别 API Key 和 URL 格式\n */\n\n/**\n * 检测字符串是否看起来像配置键名\n * - 全大写 + 下划线分隔的多段单词:API_TIMEOUT_MS, ANTHROPIC_BASE_URL\n * - 全小写常见配置字段名:api_key, base_url, url, name, token, auth, secret, endpoint, model, type\n * (来自用户粘贴 yaml/markdown 配置时的字段名残留,而不是真实密钥)\n */\nconst looksLikeConfigKey = (token: string): boolean => {\n // 1) 全大写字母 + 下划线,且由多个单词组成(至少包含一个下划线分隔的段)\n if (/^[A-Z][A-Z0-9]*(_[A-Z][A-Z0-9]*)+$/.test(token)) {\n return true\n }\n\n // 2) 全小写、常见配置字段名(精确白名单避免误伤短前缀密钥如 hf_short / r8_abc)\n const LOWERCASE_CONFIG_KEYS = new Set([\n 'api_key',\n 'apikey',\n 'api_secret',\n 'access_key',\n 'access_token',\n 'auth_token',\n 'auth_key',\n 'secret_key',\n 'secret',\n 'token',\n 'auth',\n 'base_url',\n 'baseurl',\n 'base',\n 'url',\n 'endpoint',\n 'host',\n 'name',\n 'model',\n 'model_name',\n 'type',\n 'service_type',\n 'provider',\n 'env',\n ])\n if (LOWERCASE_CONFIG_KEYS.has(token.toLowerCase())) {\n return true\n }\n\n return false\n}\n\n/**\n * 各平台 API Key 格式的专用正则匹配\n *\n * 国际主流:\n * - OpenAI Legacy: sk-[a-zA-Z0-9]{48}\n * - OpenAI Project: sk-proj-[a-zA-Z0-9-]{100,}\n * - Anthropic: sk-ant-api03-[a-zA-Z0-9-]{80,}\n * - Google Gemini: AIza[0-9A-Za-z-_]{35}\n * - Azure OpenAI: 32位十六进制\n *\n * 新兴生态:\n * - Hugging Face: hf_[a-zA-Z0-9]{34}\n * - Groq: gsk_[a-zA-Z0-9]{52}\n * - Perplexity: pplx-[a-zA-Z0-9]{40,}\n * - Replicate: r8_[a-zA-Z0-9]+\n * - OpenRouter: sk-or-v1-[a-zA-Z0-9]{50,}\n *\n * 国内平台:\n * - DeepSeek/Moonshot/01.AI/SiliconFlow: sk-[a-zA-Z0-9]{48} (兼容 OpenAI)\n * - 智谱 AI: [a-z0-9]{32}\\.[a-z0-9]+ (id.secret 格式)\n * - 火山引擎 Ark: UUID 格式\n * - 火山引擎 IAM: AK 开头\n */\nconst PLATFORM_KEY_PATTERNS: RegExp[] = [\n // OpenAI Project Key (新格式,最长,优先匹配)\n /^sk-proj-[a-zA-Z0-9_-]{50,}$/,\n // Anthropic Claude\n /^sk-ant-api03-[a-zA-Z0-9_-]{50,}$/,\n // OpenRouter (混合大小写字母数字)\n /^sk-or-v1-[a-zA-Z0-9]{50,}$/,\n // OpenAI Legacy / DeepSeek / Moonshot / 01.AI / SiliconFlow\n /^sk-[a-zA-Z0-9]{20,}$/,\n // Google Gemini/PaLM (通常 39 字符,允许一定范围)\n /^AIza[0-9A-Za-z_-]{30,}$/,\n // Hugging Face\n /^hf_[a-zA-Z0-9]{30,}$/,\n // Groq\n /^gsk_[a-zA-Z0-9]{40,}$/,\n // Perplexity\n /^pplx-[a-zA-Z0-9]{40,}$/,\n // Replicate\n /^r8_[a-zA-Z0-9]{20,}$/,\n // 智谱 AI (id.secret 格式)\n /^[a-zA-Z0-9]{20,}\\.[a-zA-Z0-9]{10,}$/,\n // 火山引擎 Ark (UUID 格式)\n /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,\n // 火山引擎 IAM AK\n /^AK[A-Z]{2,4}[a-zA-Z0-9]{20,}$/\n]\n\n/**\n * 检测字符串是否为有效的 API Key\n *\n * 支持的格式:\n * 1. 平台特定格式(优先匹配,准确度最高)\n * 2. 通用前缀格式:xx-xxx 或 xx_xxx(如 sk-xxx, ut_xxx, api-xxx)\n * 3. JWT 格式:eyJ 开头,包含两个点分隔的 base64 段\n * 4. 长随机字符串:≥32 字符的字母数字串(必须包含字母和数字)\n * 5. 宽松兜底:常见前缀 + 任意后缀(当以上都不匹配时)\n *\n * 排除的格式:\n * - 配置键名:全大写 + 下划线分隔(如 API_TIMEOUT_MS)\n */\nexport const isValidApiKey = (token: string): boolean => {\n // 首先排除配置键名格式\n if (looksLikeConfigKey(token)) {\n return false\n }\n\n // 1. 平台特定格式匹配(最准确)\n for (const pattern of PLATFORM_KEY_PATTERNS) {\n if (pattern.test(token)) {\n return true\n }\n }\n\n // 2. 通用前缀格式(前缀 2-6 字母 + 连字符/下划线 + 至少 10 字符后缀)\n // 后缀必须包含数字或混合大小写(随机特征)\n if (/^[a-zA-Z]{2,6}[-_][a-zA-Z0-9_-]{10,}$/.test(token)) {\n const suffix = token.replace(/^[a-zA-Z]{2,6}[-_]/, '')\n const hasDigit = /\\d/.test(suffix)\n const hasMixedCase = /[a-z]/.test(suffix) && /[A-Z]/.test(suffix)\n if (hasDigit || hasMixedCase) {\n return true\n }\n }\n\n // 3. JWT 格式 (eyJ 开头,包含两个点分隔的 base64 段,总长度 >= 20)\n if (/^eyJ[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]+\\./.test(token) && token.length >= 20) {\n return true\n }\n\n // 4. 长随机字符串(≥32 字符,必须同时包含字母和数字)\n if (token.length >= 32 && /^[a-zA-Z0-9_-]+$/.test(token) && /[a-zA-Z]/.test(token) && /\\d/.test(token)) {\n return true\n }\n\n // 5. 宽松兜底:常见 API Key 前缀 + 任意后缀(至少 1 个字符)\n // 当以上严格规则都不匹配时,放松标准识别常见格式\n // 支持:sk-xxx, api-xxx, key-xxx, ut_xxx, hf_xxx, gsk_xxx 等\n if (/^(sk|api|key|ut|hf|gsk|cr|ms|r8|pplx)[-_].+$/i.test(token)) {\n return true\n }\n\n return false\n}\n\n/**\n * 检测字符串是否为有效的 URL\n *\n * 要求:\n * - 必须以 http:// 或 https:// 开头\n * - 必须包含有效域名(域名段不能以横线开头或结尾)\n * - 支持末尾 # 标记(用于跳过自动添加 /v1)\n */\nexport const isValidUrl = (token: string): boolean => {\n // 域名段不能以横线开头或结尾,支持末尾 # 或 / 或直接结束\n return /^https?:\\/\\/[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*(:\\d+)?(\\/|#|$)/i.test(\n token\n )\n}\n\n/**\n * 从输入中提取所有 token\n * 按空白/逗号/分号/中英文冒号/换行/引号(中英文)/等号/%20 分割\n * 注意:URL 里的 `://` 冒号必须保留,否则会被误切。\n */\nconst extractTokens = (input: string): string[] => {\n // 用占位符保护整段 URL 中的所有冒号(含 `://` 协议冒号与 `host:port` 端口冒号),\n // 避免被英文冒号分隔符切碎。匹配范围:从 `http(s)://` 到下一个空白/引号/中文标点/逗号/分号/换行。\n const COLON_PLACEHOLDER = '__URLCOLON__'\n const protectedInput = input\n .replace(/%20/g, ' ')\n .replace(/https?:\\/\\/[^\\s,;,;:=\"“”'‘’\\n]+/gi, m => m.replace(/:/g, COLON_PLACEHOLDER))\n\n return protectedInput\n .split(/[\\n\\s,;,;::=\"“”'‘’]+/)\n .map(t => t.split(COLON_PLACEHOLDER).join(':'))\n .filter(t => t.length > 0)\n}\n\n/**\n * 根据 URL 路径检测服务类型,并返回清理后的 baseUrl\n * /messages → claude, /chat/completions or /embeddings → openai, /responses → responses\n */\nconst detectServiceTypeAndCleanUrl = (\n url: string\n): { serviceType: 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null; cleanedUrl: string } => {\n try {\n const cleanUrl = url.replace(/#$/, '')\n const parsed = new URL(cleanUrl)\n const path = parsed.pathname.toLowerCase()\n\n const endpointRules: Array<{ pattern: RegExp; serviceType: 'openai' | 'gemini' | 'claude' | 'responses' }> = [\n { pattern: /\\/v\\d+[a-z]*\\/messages(?:\\/|$)/, serviceType: 'claude' },\n { pattern: /\\/messages(?:\\/|$)/, serviceType: 'claude' },\n { pattern: /\\/v\\d+[a-z]*\\/embeddings(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/embeddings(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/v\\d+[a-z]*\\/chat\\/completions(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/chat\\/completions(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/v\\d+[a-z]*\\/responses(?:\\/|$)/, serviceType: 'responses' },\n { pattern: /\\/responses(?:\\/|$)/, serviceType: 'responses' },\n { pattern: /\\/v\\d+[a-z]*\\/models\\/[^/]+:generatecontent(?:\\/|$)/, serviceType: 'gemini' },\n { pattern: /\\/models\\/[^/]+:generatecontent(?:\\/|$)/, serviceType: 'gemini' },\n { pattern: /\\/generatecontent(?:\\/|$)/, serviceType: 'gemini' },\n ]\n for (const rule of endpointRules) {\n const match = path.match(rule.pattern)\n if (!match || match.index === undefined) continue\n // 移除协议端点路径,保留 /v1 或 /v1beta 等版本前缀。\n parsed.pathname = path.slice(0, match.index) || '/'\n let result = parsed.toString().replace(/\\/$/, '')\n if (url.endsWith('#')) result += '#'\n return { serviceType: rule.serviceType, cleanedUrl: result }\n }\n\n if (parsed.hostname.toLowerCase() === 'api.githubcopilot.com') {\n return { serviceType: 'copilot', cleanedUrl: parsed.origin }\n }\n const urlKey = `${parsed.origin}${path}`.replace(/\\/$/, '')\n const knownClaudeUrls = new Set([\n 'https://cp.compshare.cn',\n 'https://api.kimi.com/coding',\n 'https://ark.cn-beijing.volces.com/api/coding',\n 'https://openrouter.ai/api',\n 'https://api-inference.modelscope.cn',\n 'https://api.easytransnote.com/coding',\n 'https://maas-coding-api.cn-huabei-1.xf-yun.com/anthropic',\n 'https://maas-api.cn-huabei-1.xf-yun.com/anthropic',\n ])\n const knownOpenAIUrls = new Set([\n 'https://api.deepseek.com/v1',\n 'https://api.xiaomimimo.com/v1',\n 'https://token-plan-cn.xiaomimimo.com/v1',\n 'https://token-plan-sgp.xiaomimimo.com/v1',\n 'https://token-plan-ams.xiaomimimo.com/v1',\n 'https://cp.compshare.cn/v1',\n 'https://api.moonshot.cn/v1',\n 'https://api.kimi.com/coding/v1',\n 'https://open.bigmodel.cn/api/coding/paas/v4',\n 'https://open.bigmodel.cn/api/paas/v4',\n 'https://api.minimax.chat/v1',\n 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n 'https://coding.dashscope.aliyuncs.com/v1',\n 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1',\n 'https://opencode.ai/zen/v1',\n 'https://opencode.ai/zen/go/v1',\n 'https://api.lkeap.cloud.tencent.com/plan/v3',\n 'https://ark.cn-beijing.volces.com/api/coding/v3',\n 'https://qianfan.baidubce.com/v2/coding',\n 'https://openrouter.ai/api/v1',\n 'https://api-inference.modelscope.cn/v1',\n 'https://api.easytransnote.com/coding/v1',\n 'https://maas-coding-api.cn-huabei-1.xf-yun.com/v2',\n 'https://maas-api.cn-huabei-1.xf-yun.com/v2',\n ])\n if (knownClaudeUrls.has(urlKey)) {\n return { serviceType: 'claude', cleanedUrl: cleanUrl }\n }\n if (knownOpenAIUrls.has(urlKey)) {\n return { serviceType: 'openai', cleanedUrl: cleanUrl }\n }\n\n const hintText = `${parsed.hostname.toLowerCase()} ${path}`\n if (/\\b(anthropic|claude)\\b/.test(hintText) || /(^|\\/)(anthropic|claude)(?:\\/|$)/.test(path)) {\n return { serviceType: 'claude', cleanedUrl: url }\n }\n if (/\\bgemini\\b/.test(hintText) || /generativelanguage\\.googleapis\\.com$/i.test(parsed.hostname)) {\n return { serviceType: 'gemini', cleanedUrl: url }\n }\n if (/\\bresponses\\b/.test(hintText)) {\n return { serviceType: 'responses', cleanedUrl: url }\n }\n if (/\\b(openai|chatgpt)\\b/.test(hintText)) {\n return { serviceType: 'openai', cleanedUrl: url }\n }\n\n // 剔除常见第三方面板路径,仅保留 origin 作为 baseUrl\n const dashboardPathPrefixes = [\n '/admin',\n '/console',\n '/dashboard',\n '/keys',\n '/api-keys',\n '/apikeys',\n '/panel',\n '/token',\n '/profile',\n '/usage',\n '/wallet',\n '/log',\n '/pricing'\n ]\n if (dashboardPathPrefixes.some(prefix => path === prefix || path.startsWith(prefix + '/'))) {\n let result = parsed.origin\n if (url.endsWith('#')) result += '#'\n return { serviceType: null, cleanedUrl: result }\n }\n } catch {\n // 忽略解析错误\n }\n return { serviceType: null, cleanedUrl: url }\n}\n\n// 保留导出以兼容可能的外部使用\nexport const detectServiceType = (url: string): 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null => {\n return detectServiceTypeAndCleanUrl(url).serviceType\n}\n\n/** Base URL 最大数量限制 */\nconst MAX_BASE_URLS = 10\n\n/**\n * 对 Base URL 列表去重(等效 URL 仅保留一条;尾部 # 视为不同语义)\n */\nexport function deduplicateBaseUrls(urls: string[], serviceType: ServiceType = ''): string[] {\n return deduplicateEquivalentBaseUrls(urls, serviceType)\n}\n\n/**\n * 解析快速输入内容,提取 URL 和 API Keys\n *\n * 支持的格式:\n * 1. 纯文本:URL 和 API Key 以空白/逗号/分号/等号分隔\n * 2. 引号包裹:从 \"xxx\" 或 'xxx' 中提取内容(支持 JSON 配置格式)\n * 3. 多 Base URL:所有符合 HTTP 链接格式的都作为 baseUrl(最多 10 个)\n */\nexport const parseQuickInput = (\n input: string,\n fallbackServiceType: ServiceType = ''\n): {\n detectedBaseUrl: string\n detectedBaseUrls: string[]\n rawBaseUrls: string[]\n detectedApiKeys: string[]\n detectedServiceType: 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null\n} => {\n const rawUrls: string[] = []\n let detectedServiceType: 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null = null\n const detectedApiKeys: string[] = []\n\n const tokens = extractTokens(input)\n\n for (const token of tokens) {\n if (isValidUrl(token)) {\n const endsWithHash = token.endsWith('#')\n let url = endsWithHash ? token.slice(0, -1) : token\n url = url.replace(/\\/$/, '')\n const fullUrl = endsWithHash ? url + '#' : url\n\n // 检测协议并清理 URL(移除端点路径)\n const { serviceType, cleanedUrl } = detectServiceTypeAndCleanUrl(fullUrl)\n\n rawUrls.push(cleanedUrl)\n // 使用第一个 URL 的服务类型\n if (!detectedServiceType) {\n detectedServiceType = serviceType\n }\n continue\n }\n\n if (isValidApiKey(token) && !detectedApiKeys.includes(token)) {\n detectedApiKeys.push(token)\n }\n }\n\n // 先去重,再限制数量,避免误拒绝等效 URL\n const deduplicatedRawBaseUrls = Array.from(new Set(rawUrls)).slice(0, MAX_BASE_URLS)\n const detectedBaseUrls = deduplicateBaseUrls(deduplicatedRawBaseUrls, detectedServiceType || fallbackServiceType).slice(0, MAX_BASE_URLS)\n\n return {\n detectedBaseUrl: detectedBaseUrls[0] || '',\n detectedBaseUrls,\n rawBaseUrls: deduplicatedRawBaseUrls,\n detectedApiKeys,\n detectedServiceType\n }\n}\n"}, "e972d82e6b4b3edf23f948edbd0db2d4c80db655": {"t": 1785809885.2262404, "n": 12239, "h": "548ffb5a6f04bb4e", "c": "import { deduplicateEquivalentBaseUrls, type ServiceType } from './base-url-semantics'\n\n/**\n * 快速添加渠道 - 输入解析工具\n *\n * 用于识别 API Key 和 URL 格式\n */\n\n/**\n * 检测字符串是否看起来像配置键名\n * - 全大写 + 下划线分隔的多段单词:API_TIMEOUT_MS, ANTHROPIC_BASE_URL\n * - 全小写常见配置字段名:api_key, base_url, url, name, token, auth, secret, endpoint, model, type\n * (来自用户粘贴 yaml/markdown 配置时的字段名残留,而不是真实密钥)\n */\nconst looksLikeConfigKey = (token: string): boolean => {\n // 1) 全大写字母 + 下划线,且由多个单词组成(至少包含一个下划线分隔的段)\n if (/^[A-Z][A-Z0-9]*(_[A-Z][A-Z0-9]*)+$/.test(token)) {\n return true\n }\n\n // 2) 全小写、常见配置字段名(精确白名单避免误伤短前缀密钥如 hf_short / r8_abc)\n const LOWERCASE_CONFIG_KEYS = new Set([\n 'api_key',\n 'apikey',\n 'api_secret',\n 'access_key',\n 'access_token',\n 'auth_token',\n 'auth_key',\n 'secret_key',\n 'secret',\n 'token',\n 'auth',\n 'base_url',\n 'baseurl',\n 'base',\n 'url',\n 'endpoint',\n 'host',\n 'name',\n 'model',\n 'model_name',\n 'type',\n 'service_type',\n 'provider',\n 'env',\n ])\n if (LOWERCASE_CONFIG_KEYS.has(token.toLowerCase())) {\n return true\n }\n\n return false\n}\n\n/**\n * 各平台 API Key 格式的专用正则匹配\n *\n * 国际主流:\n * - OpenAI Legacy: sk-[a-zA-Z0-9]{48}\n * - OpenAI Project: sk-proj-[a-zA-Z0-9-]{100,}\n * - Anthropic: sk-ant-api03-[a-zA-Z0-9-]{80,}\n * - Google Gemini: AIza[0-9A-Za-z-_]{35}\n * - Azure OpenAI: 32位十六进制\n *\n * 新兴生态:\n * - Hugging Face: hf_[a-zA-Z0-9]{34}\n * - Groq: gsk_[a-zA-Z0-9]{52}\n * - Perplexity: pplx-[a-zA-Z0-9]{40,}\n * - Replicate: r8_[a-zA-Z0-9]+\n * - OpenRouter: sk-or-v1-[a-zA-Z0-9]{50,}\n *\n * 国内平台:\n * - DeepSeek/Moonshot/01.AI/SiliconFlow: sk-[a-zA-Z0-9]{48} (兼容 OpenAI)\n * - 智谱 AI: [a-z0-9]{32}\\.[a-z0-9]+ (id.secret 格式)\n * - 火山引擎 Ark: UUID 格式\n * - 火山引擎 IAM: AK 开头\n */\nconst PLATFORM_KEY_PATTERNS: RegExp[] = [\n // OpenAI Project Key (新格式,最长,优先匹配)\n /^sk-proj-[a-zA-Z0-9_-]{50,}$/,\n // Anthropic Claude\n /^sk-ant-api03-[a-zA-Z0-9_-]{50,}$/,\n // OpenRouter (混合大小写字母数字)\n /^sk-or-v1-[a-zA-Z0-9]{50,}$/,\n // OpenAI Legacy / DeepSeek / Moonshot / 01.AI / SiliconFlow\n /^sk-[a-zA-Z0-9]{20,}$/,\n // Google Gemini/PaLM (通常 39 字符,允许一定范围)\n /^AIza[0-9A-Za-z_-]{30,}$/,\n // Hugging Face\n /^hf_[a-zA-Z0-9]{30,}$/,\n // Groq\n /^gsk_[a-zA-Z0-9]{40,}$/,\n // Perplexity\n /^pplx-[a-zA-Z0-9]{40,}$/,\n // Replicate\n /^r8_[a-zA-Z0-9]{20,}$/,\n // 智谱 AI (id.secret 格式)\n /^[a-zA-Z0-9]{20,}\\.[a-zA-Z0-9]{10,}$/,\n // 火山引擎 Ark (UUID 格式)\n /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,\n // 火山引擎 IAM AK\n /^AK[A-Z]{2,4}[a-zA-Z0-9]{20,}$/\n]\n\n/**\n * 检测字符串是否为有效的 API Key\n *\n * 支持的格式:\n * 1. 平台特定格式(优先匹配,准确度最高)\n * 2. 通用前缀格式:xx-xxx 或 xx_xxx(如 sk-xxx, ut_xxx, api-xxx)\n * 3. JWT 格式:eyJ 开头,包含两个点分隔的 base64 段\n * 4. 长随机字符串:≥32 字符的字母数字串(必须包含字母和数字)\n * 5. 宽松兜底:常见前缀 + 任意后缀(当以上都不匹配时)\n *\n * 排除的格式:\n * - 配置键名:全大写 + 下划线分隔(如 API_TIMEOUT_MS)\n */\nexport const isValidApiKey = (token: string): boolean => {\n // 首先排除配置键名格式\n if (looksLikeConfigKey(token)) {\n return false\n }\n\n // 1. 平台特定格式匹配(最准确)\n for (const pattern of PLATFORM_KEY_PATTERNS) {\n if (pattern.test(token)) {\n return true\n }\n }\n\n // 2. 通用前缀格式(前缀 2-6 字母 + 连字符/下划线 + 至少 10 字符后缀)\n // 后缀必须包含数字或混合大小写(随机特征)\n if (/^[a-zA-Z]{2,6}[-_][a-zA-Z0-9_-]{10,}$/.test(token)) {\n const suffix = token.replace(/^[a-zA-Z]{2,6}[-_]/, '')\n const hasDigit = /\\d/.test(suffix)\n const hasMixedCase = /[a-z]/.test(suffix) && /[A-Z]/.test(suffix)\n if (hasDigit || hasMixedCase) {\n return true\n }\n }\n\n // 3. JWT 格式 (eyJ 开头,包含两个点分隔的 base64 段,总长度 >= 20)\n if (/^eyJ[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]+\\./.test(token) && token.length >= 20) {\n return true\n }\n\n // 4. 长随机字符串(≥32 字符,必须同时包含字母和数字)\n if (token.length >= 32 && /^[a-zA-Z0-9_-]+$/.test(token) && /[a-zA-Z]/.test(token) && /\\d/.test(token)) {\n return true\n }\n\n // 5. 宽松兜底:常见 API Key 前缀 + 任意后缀(至少 1 个字符)\n // 当以上严格规则都不匹配时,放松标准识别常见格式\n // 支持:sk-xxx, api-xxx, key-xxx, ut_xxx, hf_xxx, gsk_xxx 等\n if (/^(sk|api|key|ut|hf|gsk|cr|ms|r8|pplx)[-_].+$/i.test(token)) {\n return true\n }\n\n return false\n}\n\n/**\n * 检测字符串是否为有效的 URL\n *\n * 要求:\n * - 必须以 http:// 或 https:// 开头\n * - 必须包含有效域名(域名段不能以横线开头或结尾)\n * - 支持末尾 # 标记(用于跳过自动添加 /v1)\n */\nexport const isValidUrl = (token: string): boolean => {\n // 域名段不能以横线开头或结尾,支持末尾 # 或 / 或直接结束\n return /^https?:\\/\\/[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*(:\\d+)?(\\/|#|$)/i.test(\n token\n )\n}\n\n/**\n * 从输入中提取所有 token\n * 按空白/逗号/分号/中英文冒号/换行/引号(中英文)/等号/%20 分割\n * 注意:URL 里的 `://` 冒号必须保留,否则会被误切。\n */\nconst extractTokens = (input: string): string[] => {\n // 用占位符保护整段 URL 中的所有冒号(含 `://` 协议冒号与 `host:port` 端口冒号),\n // 避免被英文冒号分隔符切碎。匹配范围:从 `http(s)://` 到下一个空白/引号/中文标点/逗号/分号/换行。\n const COLON_PLACEHOLDER = '__URLCOLON__'\n const protectedInput = input\n .replace(/%20/g, ' ')\n .replace(/https?:\\/\\/[^\\s,;,;:=\"“”'‘’\\n]+/gi, m => m.replace(/:/g, COLON_PLACEHOLDER))\n\n return protectedInput\n .split(/[\\n\\s,;,;::=\"“”'‘’]+/)\n .map(t => t.split(COLON_PLACEHOLDER).join(':'))\n .filter(t => t.length > 0)\n}\n\n/**\n * 根据 URL 路径检测服务类型,并返回清理后的 baseUrl\n * /messages → claude, /chat/completions or /embeddings → openai, /responses → responses\n */\nconst detectServiceTypeAndCleanUrl = (\n url: string\n): { serviceType: 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null; cleanedUrl: string } => {\n try {\n const cleanUrl = url.replace(/#$/, '')\n const parsed = new URL(cleanUrl)\n const path = parsed.pathname.toLowerCase()\n\n const endpointRules: Array<{ pattern: RegExp; serviceType: 'openai' | 'gemini' | 'claude' | 'responses' }> = [\n { pattern: /\\/v\\d+[a-z]*\\/messages(?:\\/|$)/, serviceType: 'claude' },\n { pattern: /\\/messages(?:\\/|$)/, serviceType: 'claude' },\n { pattern: /\\/v\\d+[a-z]*\\/embeddings(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/embeddings(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/v\\d+[a-z]*\\/chat\\/completions(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/chat\\/completions(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/v\\d+[a-z]*\\/responses(?:\\/|$)/, serviceType: 'responses' },\n { pattern: /\\/responses(?:\\/|$)/, serviceType: 'responses' },\n { pattern: /\\/v\\d+[a-z]*\\/models\\/[^/]+:generatecontent(?:\\/|$)/, serviceType: 'gemini' },\n { pattern: /\\/models\\/[^/]+:generatecontent(?:\\/|$)/, serviceType: 'gemini' },\n { pattern: /\\/generatecontent(?:\\/|$)/, serviceType: 'gemini' },\n ]\n for (const rule of endpointRules) {\n const match = path.match(rule.pattern)\n if (!match || match.index === undefined) continue\n // 移除协议端点路径,保留 /v1 或 /v1beta 等版本前缀。\n parsed.pathname = path.slice(0, match.index) || '/'\n let result = parsed.toString().replace(/\\/$/, '')\n if (url.endsWith('#')) result += '#'\n return { serviceType: rule.serviceType, cleanedUrl: result }\n }\n\n if (parsed.hostname.toLowerCase() === 'api.githubcopilot.com') {\n return { serviceType: 'copilot', cleanedUrl: parsed.origin }\n }\n const urlKey = `${parsed.origin}${path}`.replace(/\\/$/, '')\n const knownClaudeUrls = new Set([\n 'https://cp.compshare.cn',\n 'https://api.kimi.com/coding',\n 'https://ark.cn-beijing.volces.com/api/coding',\n 'https://openrouter.ai/api',\n 'https://api-inference.modelscope.cn',\n 'https://api.easytransnote.com/coding',\n 'https://maas-coding-api.cn-huabei-1.xf-yun.com/anthropic',\n 'https://maas-api.cn-huabei-1.xf-yun.com/anthropic',\n ])\n const knownOpenAIUrls = new Set([\n 'https://api.deepseek.com/v1',\n 'https://api.xiaomimimo.com/v1',\n 'https://token-plan-cn.xiaomimimo.com/v1',\n 'https://token-plan-sgp.xiaomimimo.com/v1',\n 'https://token-plan-ams.xiaomimimo.com/v1',\n 'https://cp.compshare.cn/v1',\n 'https://api.moonshot.cn/v1',\n 'https://api.kimi.com/coding/v1',\n 'https://open.bigmodel.cn/api/coding/paas/v4',\n 'https://open.bigmodel.cn/api/paas/v4',\n 'https://api.minimax.chat/v1',\n 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n 'https://coding.dashscope.aliyuncs.com/v1',\n 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1',\n 'https://opencode.ai/zen/v1',\n 'https://opencode.ai/zen/go/v1',\n 'https://api.lkeap.cloud.tencent.com/plan/v3',\n 'https://ark.cn-beijing.volces.com/api/coding/v3',\n 'https://qianfan.baidubce.com/v2/coding',\n 'https://openrouter.ai/api/v1',\n 'https://api-inference.modelscope.cn/v1',\n 'https://api.easytransnote.com/coding/v1',\n 'https://maas-coding-api.cn-huabei-1.xf-yun.com/v2',\n 'https://maas-api.cn-huabei-1.xf-yun.com/v2',\n ])\n if (knownClaudeUrls.has(urlKey)) {\n return { serviceType: 'claude', cleanedUrl: cleanUrl }\n }\n if (knownOpenAIUrls.has(urlKey)) {\n return { serviceType: 'openai', cleanedUrl: cleanUrl }\n }\n\n const hintText = `${parsed.hostname.toLowerCase()} ${path}`\n if (/\\b(anthropic|claude)\\b/.test(hintText) || /(^|\\/)(anthropic|claude)(?:\\/|$)/.test(path)) {\n return { serviceType: 'claude', cleanedUrl: url }\n }\n if (/\\bgemini\\b/.test(hintText) || /generativelanguage\\.googleapis\\.com$/i.test(parsed.hostname)) {\n return { serviceType: 'gemini', cleanedUrl: url }\n }\n if (/\\bresponses\\b/.test(hintText)) {\n return { serviceType: 'responses', cleanedUrl: url }\n }\n if (/\\b(openai|chatgpt)\\b/.test(hintText)) {\n return { serviceType: 'openai', cleanedUrl: url }\n }\n\n // 剔除常见第三方面板路径,仅保留 origin 作为 baseUrl\n const dashboardPathPrefixes = [\n '/admin',\n '/console',\n '/dashboard',\n '/keys',\n '/api-keys',\n '/apikeys',\n '/panel',\n '/token',\n '/profile',\n '/usage',\n '/wallet',\n '/log',\n '/pricing'\n ]\n if (dashboardPathPrefixes.some(prefix => path === prefix || path.startsWith(prefix + '/'))) {\n let result = parsed.origin\n if (url.endsWith('#')) result += '#'\n return { serviceType: null, cleanedUrl: result }\n }\n } catch {\n // 忽略解析错误\n }\n return { serviceType: null, cleanedUrl: url }\n}\n\n// 保留导出以兼容可能的外部使用\nexport const detectServiceType = (url: string): 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null => {\n return detectServiceTypeAndCleanUrl(url).serviceType\n}\n\n/** Base URL 最大数量限制 */\nconst MAX_BASE_URLS = 10\n\n/**\n * 对 Base URL 列表去重(等效 URL 仅保留一条;尾部 # 视为不同语义)\n */\nexport function deduplicateBaseUrls(urls: string[], serviceType: ServiceType = ''): string[] {\n return deduplicateEquivalentBaseUrls(urls, serviceType)\n}\n\n/**\n * 解析快速输入内容,提取 URL 和 API Keys\n *\n * 支持的格式:\n * 1. 纯文本:URL 和 API Key 以空白/逗号/分号/等号分隔\n * 2. 引号包裹:从 \"xxx\" 或 'xxx' 中提取内容(支持 JSON 配置格式)\n * 3. 多 Base URL:所有符合 HTTP 链接格式的都作为 baseUrl(最多 10 个)\n */\nexport const parseQuickInput = (\n input: string,\n fallbackServiceType: ServiceType = ''\n): {\n detectedBaseUrl: string\n detectedBaseUrls: string[]\n rawBaseUrls: string[]\n detectedApiKeys: string[]\n detectedServiceType: 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null\n} => {\n const rawUrls: string[] = []\n let detectedServiceType: 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null = null\n const detectedApiKeys: string[] = []\n\n const tokens = extractTokens(input)\n\n for (const token of tokens) {\n if (isValidUrl(token)) {\n const endsWithHash = token.endsWith('#')\n let url = endsWithHash ? token.slice(0, -1) : token\n url = url.replace(/\\/$/, '')\n const fullUrl = endsWithHash ? url + '#' : url\n\n // 检测协议并清理 URL(移除端点路径)\n const { serviceType, cleanedUrl } = detectServiceTypeAndCleanUrl(fullUrl)\n\n rawUrls.push(cleanedUrl)\n // 使用第一个 URL 的服务类型\n if (!detectedServiceType) {\n detectedServiceType = serviceType\n }\n continue\n }\n\n if (isValidApiKey(token) && !detectedApiKeys.includes(token)) {\n detectedApiKeys.push(token)\n }\n }\n\n // 先去重,再限制数量,避免误拒绝等效 URL\n const deduplicatedRawBaseUrls = Array.from(new Set(rawUrls)).slice(0, MAX_BASE_URLS)\n const detectedBaseUrls = deduplicateBaseUrls(deduplicatedRawBaseUrls, detectedServiceType || fallbackServiceType).slice(0, MAX_BASE_URLS)\n\n return {\n detectedBaseUrl: detectedBaseUrls[0] || '',\n detectedBaseUrls,\n rawBaseUrls: deduplicatedRawBaseUrls,\n detectedApiKeys,\n detectedServiceType\n }\n}\n"}, "4da6d77e15a5c2ac4bc15ce1ad1a68cfdbd2e2d0": {"t": 1785846834.9853773, "n": 12239, "h": "548ffb5a6f04bb4e", "c": "import { deduplicateEquivalentBaseUrls, type ServiceType } from './base-url-semantics'\n\n/**\n * 快速添加渠道 - 输入解析工具\n *\n * 用于识别 API Key 和 URL 格式\n */\n\n/**\n * 检测字符串是否看起来像配置键名\n * - 全大写 + 下划线分隔的多段单词:API_TIMEOUT_MS, ANTHROPIC_BASE_URL\n * - 全小写常见配置字段名:api_key, base_url, url, name, token, auth, secret, endpoint, model, type\n * (来自用户粘贴 yaml/markdown 配置时的字段名残留,而不是真实密钥)\n */\nconst looksLikeConfigKey = (token: string): boolean => {\n // 1) 全大写字母 + 下划线,且由多个单词组成(至少包含一个下划线分隔的段)\n if (/^[A-Z][A-Z0-9]*(_[A-Z][A-Z0-9]*)+$/.test(token)) {\n return true\n }\n\n // 2) 全小写、常见配置字段名(精确白名单避免误伤短前缀密钥如 hf_short / r8_abc)\n const LOWERCASE_CONFIG_KEYS = new Set([\n 'api_key',\n 'apikey',\n 'api_secret',\n 'access_key',\n 'access_token',\n 'auth_token',\n 'auth_key',\n 'secret_key',\n 'secret',\n 'token',\n 'auth',\n 'base_url',\n 'baseurl',\n 'base',\n 'url',\n 'endpoint',\n 'host',\n 'name',\n 'model',\n 'model_name',\n 'type',\n 'service_type',\n 'provider',\n 'env',\n ])\n if (LOWERCASE_CONFIG_KEYS.has(token.toLowerCase())) {\n return true\n }\n\n return false\n}\n\n/**\n * 各平台 API Key 格式的专用正则匹配\n *\n * 国际主流:\n * - OpenAI Legacy: sk-[a-zA-Z0-9]{48}\n * - OpenAI Project: sk-proj-[a-zA-Z0-9-]{100,}\n * - Anthropic: sk-ant-api03-[a-zA-Z0-9-]{80,}\n * - Google Gemini: AIza[0-9A-Za-z-_]{35}\n * - Azure OpenAI: 32位十六进制\n *\n * 新兴生态:\n * - Hugging Face: hf_[a-zA-Z0-9]{34}\n * - Groq: gsk_[a-zA-Z0-9]{52}\n * - Perplexity: pplx-[a-zA-Z0-9]{40,}\n * - Replicate: r8_[a-zA-Z0-9]+\n * - OpenRouter: sk-or-v1-[a-zA-Z0-9]{50,}\n *\n * 国内平台:\n * - DeepSeek/Moonshot/01.AI/SiliconFlow: sk-[a-zA-Z0-9]{48} (兼容 OpenAI)\n * - 智谱 AI: [a-z0-9]{32}\\.[a-z0-9]+ (id.secret 格式)\n * - 火山引擎 Ark: UUID 格式\n * - 火山引擎 IAM: AK 开头\n */\nconst PLATFORM_KEY_PATTERNS: RegExp[] = [\n // OpenAI Project Key (新格式,最长,优先匹配)\n /^sk-proj-[a-zA-Z0-9_-]{50,}$/,\n // Anthropic Claude\n /^sk-ant-api03-[a-zA-Z0-9_-]{50,}$/,\n // OpenRouter (混合大小写字母数字)\n /^sk-or-v1-[a-zA-Z0-9]{50,}$/,\n // OpenAI Legacy / DeepSeek / Moonshot / 01.AI / SiliconFlow\n /^sk-[a-zA-Z0-9]{20,}$/,\n // Google Gemini/PaLM (通常 39 字符,允许一定范围)\n /^AIza[0-9A-Za-z_-]{30,}$/,\n // Hugging Face\n /^hf_[a-zA-Z0-9]{30,}$/,\n // Groq\n /^gsk_[a-zA-Z0-9]{40,}$/,\n // Perplexity\n /^pplx-[a-zA-Z0-9]{40,}$/,\n // Replicate\n /^r8_[a-zA-Z0-9]{20,}$/,\n // 智谱 AI (id.secret 格式)\n /^[a-zA-Z0-9]{20,}\\.[a-zA-Z0-9]{10,}$/,\n // 火山引擎 Ark (UUID 格式)\n /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,\n // 火山引擎 IAM AK\n /^AK[A-Z]{2,4}[a-zA-Z0-9]{20,}$/\n]\n\n/**\n * 检测字符串是否为有效的 API Key\n *\n * 支持的格式:\n * 1. 平台特定格式(优先匹配,准确度最高)\n * 2. 通用前缀格式:xx-xxx 或 xx_xxx(如 sk-xxx, ut_xxx, api-xxx)\n * 3. JWT 格式:eyJ 开头,包含两个点分隔的 base64 段\n * 4. 长随机字符串:≥32 字符的字母数字串(必须包含字母和数字)\n * 5. 宽松兜底:常见前缀 + 任意后缀(当以上都不匹配时)\n *\n * 排除的格式:\n * - 配置键名:全大写 + 下划线分隔(如 API_TIMEOUT_MS)\n */\nexport const isValidApiKey = (token: string): boolean => {\n // 首先排除配置键名格式\n if (looksLikeConfigKey(token)) {\n return false\n }\n\n // 1. 平台特定格式匹配(最准确)\n for (const pattern of PLATFORM_KEY_PATTERNS) {\n if (pattern.test(token)) {\n return true\n }\n }\n\n // 2. 通用前缀格式(前缀 2-6 字母 + 连字符/下划线 + 至少 10 字符后缀)\n // 后缀必须包含数字或混合大小写(随机特征)\n if (/^[a-zA-Z]{2,6}[-_][a-zA-Z0-9_-]{10,}$/.test(token)) {\n const suffix = token.replace(/^[a-zA-Z]{2,6}[-_]/, '')\n const hasDigit = /\\d/.test(suffix)\n const hasMixedCase = /[a-z]/.test(suffix) && /[A-Z]/.test(suffix)\n if (hasDigit || hasMixedCase) {\n return true\n }\n }\n\n // 3. JWT 格式 (eyJ 开头,包含两个点分隔的 base64 段,总长度 >= 20)\n if (/^eyJ[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]+\\./.test(token) && token.length >= 20) {\n return true\n }\n\n // 4. 长随机字符串(≥32 字符,必须同时包含字母和数字)\n if (token.length >= 32 && /^[a-zA-Z0-9_-]+$/.test(token) && /[a-zA-Z]/.test(token) && /\\d/.test(token)) {\n return true\n }\n\n // 5. 宽松兜底:常见 API Key 前缀 + 任意后缀(至少 1 个字符)\n // 当以上严格规则都不匹配时,放松标准识别常见格式\n // 支持:sk-xxx, api-xxx, key-xxx, ut_xxx, hf_xxx, gsk_xxx 等\n if (/^(sk|api|key|ut|hf|gsk|cr|ms|r8|pplx)[-_].+$/i.test(token)) {\n return true\n }\n\n return false\n}\n\n/**\n * 检测字符串是否为有效的 URL\n *\n * 要求:\n * - 必须以 http:// 或 https:// 开头\n * - 必须包含有效域名(域名段不能以横线开头或结尾)\n * - 支持末尾 # 标记(用于跳过自动添加 /v1)\n */\nexport const isValidUrl = (token: string): boolean => {\n // 域名段不能以横线开头或结尾,支持末尾 # 或 / 或直接结束\n return /^https?:\\/\\/[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*(:\\d+)?(\\/|#|$)/i.test(\n token\n )\n}\n\n/**\n * 从输入中提取所有 token\n * 按空白/逗号/分号/中英文冒号/换行/引号(中英文)/等号/%20 分割\n * 注意:URL 里的 `://` 冒号必须保留,否则会被误切。\n */\nconst extractTokens = (input: string): string[] => {\n // 用占位符保护整段 URL 中的所有冒号(含 `://` 协议冒号与 `host:port` 端口冒号),\n // 避免被英文冒号分隔符切碎。匹配范围:从 `http(s)://` 到下一个空白/引号/中文标点/逗号/分号/换行。\n const COLON_PLACEHOLDER = '__URLCOLON__'\n const protectedInput = input\n .replace(/%20/g, ' ')\n .replace(/https?:\\/\\/[^\\s,;,;:=\"“”'‘’\\n]+/gi, m => m.replace(/:/g, COLON_PLACEHOLDER))\n\n return protectedInput\n .split(/[\\n\\s,;,;::=\"“”'‘’]+/)\n .map(t => t.split(COLON_PLACEHOLDER).join(':'))\n .filter(t => t.length > 0)\n}\n\n/**\n * 根据 URL 路径检测服务类型,并返回清理后的 baseUrl\n * /messages → claude, /chat/completions or /embeddings → openai, /responses → responses\n */\nconst detectServiceTypeAndCleanUrl = (\n url: string\n): { serviceType: 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null; cleanedUrl: string } => {\n try {\n const cleanUrl = url.replace(/#$/, '')\n const parsed = new URL(cleanUrl)\n const path = parsed.pathname.toLowerCase()\n\n const endpointRules: Array<{ pattern: RegExp; serviceType: 'openai' | 'gemini' | 'claude' | 'responses' }> = [\n { pattern: /\\/v\\d+[a-z]*\\/messages(?:\\/|$)/, serviceType: 'claude' },\n { pattern: /\\/messages(?:\\/|$)/, serviceType: 'claude' },\n { pattern: /\\/v\\d+[a-z]*\\/embeddings(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/embeddings(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/v\\d+[a-z]*\\/chat\\/completions(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/chat\\/completions(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/v\\d+[a-z]*\\/responses(?:\\/|$)/, serviceType: 'responses' },\n { pattern: /\\/responses(?:\\/|$)/, serviceType: 'responses' },\n { pattern: /\\/v\\d+[a-z]*\\/models\\/[^/]+:generatecontent(?:\\/|$)/, serviceType: 'gemini' },\n { pattern: /\\/models\\/[^/]+:generatecontent(?:\\/|$)/, serviceType: 'gemini' },\n { pattern: /\\/generatecontent(?:\\/|$)/, serviceType: 'gemini' },\n ]\n for (const rule of endpointRules) {\n const match = path.match(rule.pattern)\n if (!match || match.index === undefined) continue\n // 移除协议端点路径,保留 /v1 或 /v1beta 等版本前缀。\n parsed.pathname = path.slice(0, match.index) || '/'\n let result = parsed.toString().replace(/\\/$/, '')\n if (url.endsWith('#')) result += '#'\n return { serviceType: rule.serviceType, cleanedUrl: result }\n }\n\n if (parsed.hostname.toLowerCase() === 'api.githubcopilot.com') {\n return { serviceType: 'copilot', cleanedUrl: parsed.origin }\n }\n const urlKey = `${parsed.origin}${path}`.replace(/\\/$/, '')\n const knownClaudeUrls = new Set([\n 'https://cp.compshare.cn',\n 'https://api.kimi.com/coding',\n 'https://ark.cn-beijing.volces.com/api/coding',\n 'https://openrouter.ai/api',\n 'https://api-inference.modelscope.cn',\n 'https://api.easytransnote.com/coding',\n 'https://maas-coding-api.cn-huabei-1.xf-yun.com/anthropic',\n 'https://maas-api.cn-huabei-1.xf-yun.com/anthropic',\n ])\n const knownOpenAIUrls = new Set([\n 'https://api.deepseek.com/v1',\n 'https://api.xiaomimimo.com/v1',\n 'https://token-plan-cn.xiaomimimo.com/v1',\n 'https://token-plan-sgp.xiaomimimo.com/v1',\n 'https://token-plan-ams.xiaomimimo.com/v1',\n 'https://cp.compshare.cn/v1',\n 'https://api.moonshot.cn/v1',\n 'https://api.kimi.com/coding/v1',\n 'https://open.bigmodel.cn/api/coding/paas/v4',\n 'https://open.bigmodel.cn/api/paas/v4',\n 'https://api.minimax.chat/v1',\n 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n 'https://coding.dashscope.aliyuncs.com/v1',\n 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1',\n 'https://opencode.ai/zen/v1',\n 'https://opencode.ai/zen/go/v1',\n 'https://api.lkeap.cloud.tencent.com/plan/v3',\n 'https://ark.cn-beijing.volces.com/api/coding/v3',\n 'https://qianfan.baidubce.com/v2/coding',\n 'https://openrouter.ai/api/v1',\n 'https://api-inference.modelscope.cn/v1',\n 'https://api.easytransnote.com/coding/v1',\n 'https://maas-coding-api.cn-huabei-1.xf-yun.com/v2',\n 'https://maas-api.cn-huabei-1.xf-yun.com/v2',\n ])\n if (knownClaudeUrls.has(urlKey)) {\n return { serviceType: 'claude', cleanedUrl: cleanUrl }\n }\n if (knownOpenAIUrls.has(urlKey)) {\n return { serviceType: 'openai', cleanedUrl: cleanUrl }\n }\n\n const hintText = `${parsed.hostname.toLowerCase()} ${path}`\n if (/\\b(anthropic|claude)\\b/.test(hintText) || /(^|\\/)(anthropic|claude)(?:\\/|$)/.test(path)) {\n return { serviceType: 'claude', cleanedUrl: url }\n }\n if (/\\bgemini\\b/.test(hintText) || /generativelanguage\\.googleapis\\.com$/i.test(parsed.hostname)) {\n return { serviceType: 'gemini', cleanedUrl: url }\n }\n if (/\\bresponses\\b/.test(hintText)) {\n return { serviceType: 'responses', cleanedUrl: url }\n }\n if (/\\b(openai|chatgpt)\\b/.test(hintText)) {\n return { serviceType: 'openai', cleanedUrl: url }\n }\n\n // 剔除常见第三方面板路径,仅保留 origin 作为 baseUrl\n const dashboardPathPrefixes = [\n '/admin',\n '/console',\n '/dashboard',\n '/keys',\n '/api-keys',\n '/apikeys',\n '/panel',\n '/token',\n '/profile',\n '/usage',\n '/wallet',\n '/log',\n '/pricing'\n ]\n if (dashboardPathPrefixes.some(prefix => path === prefix || path.startsWith(prefix + '/'))) {\n let result = parsed.origin\n if (url.endsWith('#')) result += '#'\n return { serviceType: null, cleanedUrl: result }\n }\n } catch {\n // 忽略解析错误\n }\n return { serviceType: null, cleanedUrl: url }\n}\n\n// 保留导出以兼容可能的外部使用\nexport const detectServiceType = (url: string): 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null => {\n return detectServiceTypeAndCleanUrl(url).serviceType\n}\n\n/** Base URL 最大数量限制 */\nconst MAX_BASE_URLS = 10\n\n/**\n * 对 Base URL 列表去重(等效 URL 仅保留一条;尾部 # 视为不同语义)\n */\nexport function deduplicateBaseUrls(urls: string[], serviceType: ServiceType = ''): string[] {\n return deduplicateEquivalentBaseUrls(urls, serviceType)\n}\n\n/**\n * 解析快速输入内容,提取 URL 和 API Keys\n *\n * 支持的格式:\n * 1. 纯文本:URL 和 API Key 以空白/逗号/分号/等号分隔\n * 2. 引号包裹:从 \"xxx\" 或 'xxx' 中提取内容(支持 JSON 配置格式)\n * 3. 多 Base URL:所有符合 HTTP 链接格式的都作为 baseUrl(最多 10 个)\n */\nexport const parseQuickInput = (\n input: string,\n fallbackServiceType: ServiceType = ''\n): {\n detectedBaseUrl: string\n detectedBaseUrls: string[]\n rawBaseUrls: string[]\n detectedApiKeys: string[]\n detectedServiceType: 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null\n} => {\n const rawUrls: string[] = []\n let detectedServiceType: 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null = null\n const detectedApiKeys: string[] = []\n\n const tokens = extractTokens(input)\n\n for (const token of tokens) {\n if (isValidUrl(token)) {\n const endsWithHash = token.endsWith('#')\n let url = endsWithHash ? token.slice(0, -1) : token\n url = url.replace(/\\/$/, '')\n const fullUrl = endsWithHash ? url + '#' : url\n\n // 检测协议并清理 URL(移除端点路径)\n const { serviceType, cleanedUrl } = detectServiceTypeAndCleanUrl(fullUrl)\n\n rawUrls.push(cleanedUrl)\n // 使用第一个 URL 的服务类型\n if (!detectedServiceType) {\n detectedServiceType = serviceType\n }\n continue\n }\n\n if (isValidApiKey(token) && !detectedApiKeys.includes(token)) {\n detectedApiKeys.push(token)\n }\n }\n\n // 先去重,再限制数量,避免误拒绝等效 URL\n const deduplicatedRawBaseUrls = Array.from(new Set(rawUrls)).slice(0, MAX_BASE_URLS)\n const detectedBaseUrls = deduplicateBaseUrls(deduplicatedRawBaseUrls, detectedServiceType || fallbackServiceType).slice(0, MAX_BASE_URLS)\n\n return {\n detectedBaseUrl: detectedBaseUrls[0] || '',\n detectedBaseUrls,\n rawBaseUrls: deduplicatedRawBaseUrls,\n detectedApiKeys,\n detectedServiceType\n }\n}\n"}}, "frontend/src/utils/quickInputParser.ts": {"ebe33699466c4c93f605d8b16e5eb0567c127bd5": {"t": 1785674144.323501, "n": 12075, "h": "f25e27819c1b9d72", "c": "import { deduplicateEquivalentBaseUrls, stripDashboardPathFromBaseUrl, type ServiceType } from './baseUrlSemantics'\n\n/**\n * 快速添加渠道 - 输入解析工具\n *\n * 用于识别 API Key 和 URL 格式\n */\n\n/**\n * 检测字符串是否看起来像配置键名\n * - 全大写 + 下划线分隔的多段单词:API_TIMEOUT_MS, ANTHROPIC_BASE_URL\n * - 全小写常见配置字段名:api_key, base_url, url, name, token, auth, secret, endpoint, model, type\n * (来自用户粘贴 yaml/markdown 配置时的字段名残留,而不是真实密钥)\n */\nconst looksLikeConfigKey = (token: string): boolean => {\n // 1) 全大写字母 + 下划线,且由多个单词组成(至少包含一个下划线分隔的段)\n if (/^[A-Z][A-Z0-9]*(_[A-Z][A-Z0-9]*)+$/.test(token)) {\n return true\n }\n\n // 2) 全小写、常见配置字段名(精确白名单避免误伤短前缀密钥如 hf_short / r8_abc)\n const LOWERCASE_CONFIG_KEYS = new Set([\n 'api_key',\n 'apikey',\n 'api_secret',\n 'access_key',\n 'access_token',\n 'auth_token',\n 'auth_key',\n 'secret_key',\n 'secret',\n 'token',\n 'auth',\n 'base_url',\n 'baseurl',\n 'base',\n 'url',\n 'endpoint',\n 'host',\n 'name',\n 'model',\n 'model_name',\n 'type',\n 'service_type',\n 'provider',\n 'env',\n ])\n if (LOWERCASE_CONFIG_KEYS.has(token.toLowerCase())) {\n return true\n }\n\n return false\n}\n\n/**\n * 各平台 API Key 格式的专用正则匹配\n *\n * 国际主流:\n * - OpenAI Legacy: sk-[a-zA-Z0-9]{48}\n * - OpenAI Project: sk-proj-[a-zA-Z0-9-]{100,}\n * - Anthropic: sk-ant-api03-[a-zA-Z0-9-]{80,}\n * - Google Gemini: AIza[0-9A-Za-z-_]{35}\n * - Azure OpenAI: 32位十六进制\n *\n * 新兴生态:\n * - Hugging Face: hf_[a-zA-Z0-9]{34}\n * - Groq: gsk_[a-zA-Z0-9]{52}\n * - Perplexity: pplx-[a-zA-Z0-9]{40,}\n * - Replicate: r8_[a-zA-Z0-9]+\n * - OpenRouter: sk-or-v1-[a-zA-Z0-9]{50,}\n *\n * 国内平台:\n * - DeepSeek/Moonshot/01.AI/SiliconFlow: sk-[a-zA-Z0-9]{48} (兼容 OpenAI)\n * - 智谱 AI: [a-z0-9]{32}\\.[a-z0-9]+ (id.secret 格式)\n * - 火山引擎 Ark: UUID 格式\n * - 火山引擎 IAM: AK 开头\n */\nconst ZHIPU_API_KEY_PATTERN = /^[a-zA-Z0-9]{20,}\\.[a-zA-Z0-9]{10,}$/\n\nconst PLATFORM_KEY_PATTERNS: RegExp[] = [\n // OpenAI Project Key (新格式,最长,优先匹配)\n /^sk-proj-[a-zA-Z0-9_-]{50,}$/,\n // Anthropic Claude\n /^sk-ant-api03-[a-zA-Z0-9_-]{50,}$/,\n // OpenRouter (混合大小写字母数字)\n /^sk-or-v1-[a-zA-Z0-9]{50,}$/,\n // OpenAI Legacy / DeepSeek / Moonshot / 01.AI / SiliconFlow\n /^sk-[a-zA-Z0-9]{20,}$/,\n // Google Gemini/PaLM (通常 39 字符,允许一定范围)\n /^AIza[0-9A-Za-z_-]{30,}$/,\n // Hugging Face\n /^hf_[a-zA-Z0-9]{30,}$/,\n // Groq\n /^gsk_[a-zA-Z0-9]{40,}$/,\n // Perplexity\n /^pplx-[a-zA-Z0-9]{40,}$/,\n // Replicate\n /^r8_[a-zA-Z0-9]{20,}$/,\n // 智谱 AI (id.secret 格式)\n ZHIPU_API_KEY_PATTERN,\n // 火山引擎 Ark (UUID 格式)\n /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,\n // 火山引擎 IAM AK\n /^AK[A-Z]{2,4}[a-zA-Z0-9]{20,}$/\n]\n\n/** 智谱传统 API Key 使用唯一的 id.secret 格式,可用于无 URL 时的 provider 推断。 */\nexport const isZhipuApiKey = (token: string): boolean => ZHIPU_API_KEY_PATTERN.test(token.trim())\n\n/**\n * 检测字符串是否为有效的 API Key\n *\n * 支持的格式:\n * 1. 平台特定格式(优先匹配,准确度最高)\n * 2. 通用前缀格式:xx-xxx 或 xx_xxx(如 sk-xxx, ut_xxx, api-xxx)\n * 3. JWT 格式:eyJ 开头,包含两个点分隔的 base64 段\n * 4. 长随机字符串:≥32 字符的字母数字串(必须包含字母和数字)\n * 5. 宽松兜底:常见前缀 + 任意后缀(当以上都不匹配时)\n *\n * 排除的格式:\n * - 配置键名:全大写 + 下划线分隔(如 API_TIMEOUT_MS)\n */\nexport const isValidApiKey = (token: string): boolean => {\n // 首先排除配置键名格式\n if (looksLikeConfigKey(token)) {\n return false\n }\n\n // 1. 平台特定格式匹配(最准确)\n for (const pattern of PLATFORM_KEY_PATTERNS) {\n if (pattern.test(token)) {\n return true\n }\n }\n\n // 2. 通用前缀格式(前缀 2-6 字母 + 连字符/下划线 + 至少 10 字符后缀)\n // 后缀必须包含数字或混合大小写(随机特征)\n if (/^[a-zA-Z]{2,6}[-_][a-zA-Z0-9_-]{10,}$/.test(token)) {\n const suffix = token.replace(/^[a-zA-Z]{2,6}[-_]/, '')\n const hasDigit = /\\d/.test(suffix)\n const hasMixedCase = /[a-z]/.test(suffix) && /[A-Z]/.test(suffix)\n if (hasDigit || hasMixedCase) {\n return true\n }\n }\n\n // 3. JWT 格式 (eyJ 开头,包含两个点分隔的 base64 段,总长度 >= 20)\n if (/^eyJ[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]+\\./.test(token) && token.length >= 20) {\n return true\n }\n\n // 4. 长随机字符串(≥32 字符,必须同时包含字母和数字)\n if (token.length >= 32 && /^[a-zA-Z0-9_-]+$/.test(token) && /[a-zA-Z]/.test(token) && /\\d/.test(token)) {\n return true\n }\n\n // 5. 宽松兜底:常见 API Key 前缀 + 任意后缀(至少 1 个字符)\n // 当以上严格规则都不匹配时,放松标准识别常见格式\n // 支持:sk-xxx, api-xxx, key-xxx, ut_xxx, hf_xxx, gsk_xxx 等\n if (/^(sk|api|key|ut|hf|gsk|cr|ms|r8|pplx)[-_].+$/i.test(token)) {\n return true\n }\n\n return false\n}\n\n/**\n * 检测字符串是否为有效的 URL\n *\n * 要求:\n * - 必须以 http:// 或 https:// 开头\n * - 必须包含有效域名(域名段不能以横线开头或结尾)\n * - 支持末尾 # 标记(用于跳过自动添加 /v1)\n */\nexport const isValidUrl = (token: string): boolean => {\n // 域名段不能以横线开头或结尾,支持末尾 # 或 / 或直接结束\n return /^https?:\\/\\/[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*(:\\d+)?(\\/|#|$)/i.test(\n token\n )\n}\n\n/**\n * 从输入中提取所有 token\n * 按空白/逗号/分号/中英文冒号/换行/引号(中英文)/等号/%20 分割\n * 注意:URL 里的 `://` 冒号必须保留,否则会被误切。\n */\nconst extractTokens = (input: string): string[] => {\n // 用占位符保护整段 URL 中的所有冒号(含 `://` 协议冒号与 `host:port` 端口冒号),\n // 避免被英文冒号分隔符切碎。匹配范围:从 `http(s)://` 到下一个空白/引号/中文标点/逗号/分号/换行。\n const COLON_PLACEHOLDER = '__URLCOLON__'\n const protectedInput = input\n .replace(/%20/g, ' ')\n .replace(/https?:\\/\\/[^\\s,;,;:=\"“”'‘’\\n]+/gi, m => m.replace(/:/g, COLON_PLACEHOLDER))\n\n return protectedInput\n .split(/[\\n\\s,;,;::=\"“”'‘’]+/)\n .map(t => t.split(COLON_PLACEHOLDER).join(':'))\n .filter(t => t.length > 0)\n}\n\n/**\n * 根据 URL 路径检测服务类型,并返回清理后的 baseUrl\n * /messages → claude, /chat/completions → openai, /responses → responses\n */\nconst detectServiceTypeAndCleanUrl = (\n url: string\n): { serviceType: 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null; cleanedUrl: string } => {\n try {\n const cleanUrl = url.replace(/#$/, '')\n const parsed = new URL(cleanUrl)\n const path = parsed.pathname.toLowerCase()\n\n const endpointRules: Array<{ pattern: RegExp; serviceType: 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' }> = [\n { pattern: /\\/v\\d+[a-z]*\\/messages(?:\\/|$)/, serviceType: 'claude' },\n { pattern: /\\/messages(?:\\/|$)/, serviceType: 'claude' },\n { pattern: /\\/v\\d+[a-z]*\\/embeddings(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/embeddings(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/v\\d+[a-z]*\\/chat\\/completions(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/chat\\/completions(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/v\\d+[a-z]*\\/responses(?:\\/|$)/, serviceType: 'responses' },\n { pattern: /\\/responses(?:\\/|$)/, serviceType: 'responses' },\n { pattern: /\\/v\\d+[a-z]*\\/models\\/[^/]+:generatecontent(?:\\/|$)/, serviceType: 'gemini' },\n { pattern: /\\/models\\/[^/]+:generatecontent(?:\\/|$)/, serviceType: 'gemini' },\n { pattern: /\\/generatecontent(?:\\/|$)/, serviceType: 'gemini' },\n ]\n for (const rule of endpointRules) {\n const match = path.match(rule.pattern)\n if (!match || match.index === undefined) continue\n // 移除协议端点路径,保留 /v1 或 /v1beta 等版本前缀。\n parsed.pathname = path.slice(0, match.index) || '/'\n let result = parsed.toString().replace(/\\/$/, '')\n if (url.endsWith('#')) result += '#'\n return { serviceType: rule.serviceType, cleanedUrl: result }\n }\n\n const urlKey = `${parsed.origin}${path}`.replace(/\\/$/, '')\n if (parsed.hostname.toLowerCase() === 'api.githubcopilot.com') {\n return { serviceType: 'copilot', cleanedUrl: parsed.origin }\n }\n const knownClaudeUrls = new Set([\n 'https://cp.compshare.cn',\n 'https://api.kimi.com/coding',\n 'https://ark.cn-beijing.volces.com/api/coding',\n 'https://openrouter.ai/api',\n 'https://api-inference.modelscope.cn',\n 'https://api.easytransnote.com/coding',\n ])\n const knownOpenAIUrls = new Set([\n 'https://api.deepseek.com/v1',\n 'https://api.xiaomimimo.com/v1',\n 'https://token-plan-cn.xiaomimimo.com/v1',\n 'https://token-plan-sgp.xiaomimimo.com/v1',\n 'https://token-plan-ams.xiaomimimo.com/v1',\n 'https://cp.compshare.cn/v1',\n 'https://api.moonshot.cn/v1',\n 'https://api.kimi.com/coding/v1',\n 'https://open.bigmodel.cn/api/coding/paas/v4',\n 'https://open.bigmodel.cn/api/paas/v4',\n 'https://api.minimax.chat/v1',\n 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n 'https://coding.dashscope.aliyuncs.com/v1',\n 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1',\n 'https://opencode.ai/zen/v1',\n 'https://opencode.ai/zen/go/v1',\n 'https://api.lkeap.cloud.tencent.com/plan/v3',\n 'https://ark.cn-beijing.volces.com/api/coding/v3',\n 'https://qianfan.baidubce.com/v2/coding',\n 'https://runapi.co/api/v1',\n 'https://unity2.ai/v1',\n 'https://openrouter.ai/api/v1',\n 'https://api-inference.modelscope.cn/v1',\n 'https://api.easytransnote.com/coding/v1',\n ])\n if (knownClaudeUrls.has(urlKey)) {\n return { serviceType: 'claude', cleanedUrl: cleanUrl }\n }\n if (knownOpenAIUrls.has(urlKey)) {\n return { serviceType: 'openai', cleanedUrl: cleanUrl }\n }\n\n const hintText = `${parsed.hostname.toLowerCase()} ${path}`\n if (/\\b(anthropic|claude)\\b/.test(hintText) || /(^|\\/)(anthropic|claude)(?:\\/|$)/.test(path)) {\n return { serviceType: 'claude', cleanedUrl: url }\n }\n if (/\\bgemini\\b/.test(hintText) || /generativelanguage\\.googleapis\\.com$/i.test(parsed.hostname)) {\n return { serviceType: 'gemini', cleanedUrl: url }\n }\n if (/\\bresponses\\b/.test(hintText)) {\n return { serviceType: 'responses', cleanedUrl: url }\n }\n if (/\\bcopilot\\b/.test(hintText)) {\n return { serviceType: 'copilot', cleanedUrl: url }\n }\n if (/\\b(openai|chatgpt)\\b/.test(hintText)) {\n return { serviceType: 'openai', cleanedUrl: url }\n }\n\n // 剔除常见第三方面板路径,仅保留 origin 作为 baseUrl。\n const dashboardUrl = stripDashboardPathFromBaseUrl(url)\n if (dashboardUrl !== url) {\n return { serviceType: null, cleanedUrl: dashboardUrl }\n }\n } catch {\n // 忽略解析错误\n }\n return { serviceType: null, cleanedUrl: url }\n}\n\n// 保留导出以兼容可能的外部使用\nexport const detectServiceType = (url: string): 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null => {\n return detectServiceTypeAndCleanUrl(url).serviceType\n}\n\n/** Base URL 最大数量限制 */\nconst MAX_BASE_URLS = 10\n\n/**\n * 对 Base URL 列表去重(等效 URL 仅保留一条;尾部 # 视为不同语义)\n */\nexport function deduplicateBaseUrls(urls: string[], serviceType: ServiceType = ''): string[] {\n return deduplicateEquivalentBaseUrls(urls, serviceType)\n}\n\n/**\n * 解析快速输入内容,提取 URL 和 API Keys\n *\n * 支持的格式:\n * 1. 纯文本:URL 和 API Key 以空白/逗号/分号/等号分隔\n * 2. 引号包裹:从 \"xxx\" 或 'xxx' 中提取内容(支持 JSON 配置格式)\n * 3. 多 Base URL:所有符合 HTTP 链接格式的都作为 baseUrl(最多 10 个)\n */\nexport const parseQuickInput = (\n input: string,\n fallbackServiceType: ServiceType = ''\n): {\n detectedBaseUrl: string\n detectedBaseUrls: string[]\n rawBaseUrls: string[]\n detectedApiKeys: string[]\n detectedServiceType: 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null\n} => {\n const rawUrls: string[] = []\n let detectedServiceType: 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null = null\n const detectedApiKeys: string[] = []\n\n const tokens = extractTokens(input)\n\n for (const token of tokens) {\n if (isValidUrl(token)) {\n const endsWithHash = token.endsWith('#')\n let url = endsWithHash ? token.slice(0, -1) : token\n url = url.replace(/\\/$/, '')\n const fullUrl = endsWithHash ? url + '#' : url\n\n // 检测协议并清理 URL(移除端点路径)\n const { serviceType, cleanedUrl } = detectServiceTypeAndCleanUrl(fullUrl)\n\n rawUrls.push(cleanedUrl)\n // 使用第一个 URL 的服务类型\n if (!detectedServiceType) {\n detectedServiceType = serviceType\n }\n continue\n }\n\n if (isValidApiKey(token) && !detectedApiKeys.includes(token)) {\n detectedApiKeys.push(token)\n }\n }\n\n // 先去重,再限制数量,避免误拒绝等效 URL\n const deduplicatedRawBaseUrls = Array.from(new Set(rawUrls)).slice(0, MAX_BASE_URLS)\n const detectedBaseUrls = deduplicateBaseUrls(deduplicatedRawBaseUrls, detectedServiceType || fallbackServiceType).slice(0, MAX_BASE_URLS)\n\n return {\n detectedBaseUrl: detectedBaseUrls[0] || '',\n detectedBaseUrls,\n rawBaseUrls: deduplicatedRawBaseUrls,\n detectedApiKeys,\n detectedServiceType\n }\n}\n"}, "784041ebc2a680321b17c44db2f89e95b9470dbe": {"t": 1785809839.5674403, "n": 12075, "h": "f25e27819c1b9d72", "c": "import { deduplicateEquivalentBaseUrls, stripDashboardPathFromBaseUrl, type ServiceType } from './baseUrlSemantics'\n\n/**\n * 快速添加渠道 - 输入解析工具\n *\n * 用于识别 API Key 和 URL 格式\n */\n\n/**\n * 检测字符串是否看起来像配置键名\n * - 全大写 + 下划线分隔的多段单词:API_TIMEOUT_MS, ANTHROPIC_BASE_URL\n * - 全小写常见配置字段名:api_key, base_url, url, name, token, auth, secret, endpoint, model, type\n * (来自用户粘贴 yaml/markdown 配置时的字段名残留,而不是真实密钥)\n */\nconst looksLikeConfigKey = (token: string): boolean => {\n // 1) 全大写字母 + 下划线,且由多个单词组成(至少包含一个下划线分隔的段)\n if (/^[A-Z][A-Z0-9]*(_[A-Z][A-Z0-9]*)+$/.test(token)) {\n return true\n }\n\n // 2) 全小写、常见配置字段名(精确白名单避免误伤短前缀密钥如 hf_short / r8_abc)\n const LOWERCASE_CONFIG_KEYS = new Set([\n 'api_key',\n 'apikey',\n 'api_secret',\n 'access_key',\n 'access_token',\n 'auth_token',\n 'auth_key',\n 'secret_key',\n 'secret',\n 'token',\n 'auth',\n 'base_url',\n 'baseurl',\n 'base',\n 'url',\n 'endpoint',\n 'host',\n 'name',\n 'model',\n 'model_name',\n 'type',\n 'service_type',\n 'provider',\n 'env',\n ])\n if (LOWERCASE_CONFIG_KEYS.has(token.toLowerCase())) {\n return true\n }\n\n return false\n}\n\n/**\n * 各平台 API Key 格式的专用正则匹配\n *\n * 国际主流:\n * - OpenAI Legacy: sk-[a-zA-Z0-9]{48}\n * - OpenAI Project: sk-proj-[a-zA-Z0-9-]{100,}\n * - Anthropic: sk-ant-api03-[a-zA-Z0-9-]{80,}\n * - Google Gemini: AIza[0-9A-Za-z-_]{35}\n * - Azure OpenAI: 32位十六进制\n *\n * 新兴生态:\n * - Hugging Face: hf_[a-zA-Z0-9]{34}\n * - Groq: gsk_[a-zA-Z0-9]{52}\n * - Perplexity: pplx-[a-zA-Z0-9]{40,}\n * - Replicate: r8_[a-zA-Z0-9]+\n * - OpenRouter: sk-or-v1-[a-zA-Z0-9]{50,}\n *\n * 国内平台:\n * - DeepSeek/Moonshot/01.AI/SiliconFlow: sk-[a-zA-Z0-9]{48} (兼容 OpenAI)\n * - 智谱 AI: [a-z0-9]{32}\\.[a-z0-9]+ (id.secret 格式)\n * - 火山引擎 Ark: UUID 格式\n * - 火山引擎 IAM: AK 开头\n */\nconst ZHIPU_API_KEY_PATTERN = /^[a-zA-Z0-9]{20,}\\.[a-zA-Z0-9]{10,}$/\n\nconst PLATFORM_KEY_PATTERNS: RegExp[] = [\n // OpenAI Project Key (新格式,最长,优先匹配)\n /^sk-proj-[a-zA-Z0-9_-]{50,}$/,\n // Anthropic Claude\n /^sk-ant-api03-[a-zA-Z0-9_-]{50,}$/,\n // OpenRouter (混合大小写字母数字)\n /^sk-or-v1-[a-zA-Z0-9]{50,}$/,\n // OpenAI Legacy / DeepSeek / Moonshot / 01.AI / SiliconFlow\n /^sk-[a-zA-Z0-9]{20,}$/,\n // Google Gemini/PaLM (通常 39 字符,允许一定范围)\n /^AIza[0-9A-Za-z_-]{30,}$/,\n // Hugging Face\n /^hf_[a-zA-Z0-9]{30,}$/,\n // Groq\n /^gsk_[a-zA-Z0-9]{40,}$/,\n // Perplexity\n /^pplx-[a-zA-Z0-9]{40,}$/,\n // Replicate\n /^r8_[a-zA-Z0-9]{20,}$/,\n // 智谱 AI (id.secret 格式)\n ZHIPU_API_KEY_PATTERN,\n // 火山引擎 Ark (UUID 格式)\n /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,\n // 火山引擎 IAM AK\n /^AK[A-Z]{2,4}[a-zA-Z0-9]{20,}$/\n]\n\n/** 智谱传统 API Key 使用唯一的 id.secret 格式,可用于无 URL 时的 provider 推断。 */\nexport const isZhipuApiKey = (token: string): boolean => ZHIPU_API_KEY_PATTERN.test(token.trim())\n\n/**\n * 检测字符串是否为有效的 API Key\n *\n * 支持的格式:\n * 1. 平台特定格式(优先匹配,准确度最高)\n * 2. 通用前缀格式:xx-xxx 或 xx_xxx(如 sk-xxx, ut_xxx, api-xxx)\n * 3. JWT 格式:eyJ 开头,包含两个点分隔的 base64 段\n * 4. 长随机字符串:≥32 字符的字母数字串(必须包含字母和数字)\n * 5. 宽松兜底:常见前缀 + 任意后缀(当以上都不匹配时)\n *\n * 排除的格式:\n * - 配置键名:全大写 + 下划线分隔(如 API_TIMEOUT_MS)\n */\nexport const isValidApiKey = (token: string): boolean => {\n // 首先排除配置键名格式\n if (looksLikeConfigKey(token)) {\n return false\n }\n\n // 1. 平台特定格式匹配(最准确)\n for (const pattern of PLATFORM_KEY_PATTERNS) {\n if (pattern.test(token)) {\n return true\n }\n }\n\n // 2. 通用前缀格式(前缀 2-6 字母 + 连字符/下划线 + 至少 10 字符后缀)\n // 后缀必须包含数字或混合大小写(随机特征)\n if (/^[a-zA-Z]{2,6}[-_][a-zA-Z0-9_-]{10,}$/.test(token)) {\n const suffix = token.replace(/^[a-zA-Z]{2,6}[-_]/, '')\n const hasDigit = /\\d/.test(suffix)\n const hasMixedCase = /[a-z]/.test(suffix) && /[A-Z]/.test(suffix)\n if (hasDigit || hasMixedCase) {\n return true\n }\n }\n\n // 3. JWT 格式 (eyJ 开头,包含两个点分隔的 base64 段,总长度 >= 20)\n if (/^eyJ[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]+\\./.test(token) && token.length >= 20) {\n return true\n }\n\n // 4. 长随机字符串(≥32 字符,必须同时包含字母和数字)\n if (token.length >= 32 && /^[a-zA-Z0-9_-]+$/.test(token) && /[a-zA-Z]/.test(token) && /\\d/.test(token)) {\n return true\n }\n\n // 5. 宽松兜底:常见 API Key 前缀 + 任意后缀(至少 1 个字符)\n // 当以上严格规则都不匹配时,放松标准识别常见格式\n // 支持:sk-xxx, api-xxx, key-xxx, ut_xxx, hf_xxx, gsk_xxx 等\n if (/^(sk|api|key|ut|hf|gsk|cr|ms|r8|pplx)[-_].+$/i.test(token)) {\n return true\n }\n\n return false\n}\n\n/**\n * 检测字符串是否为有效的 URL\n *\n * 要求:\n * - 必须以 http:// 或 https:// 开头\n * - 必须包含有效域名(域名段不能以横线开头或结尾)\n * - 支持末尾 # 标记(用于跳过自动添加 /v1)\n */\nexport const isValidUrl = (token: string): boolean => {\n // 域名段不能以横线开头或结尾,支持末尾 # 或 / 或直接结束\n return /^https?:\\/\\/[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*(:\\d+)?(\\/|#|$)/i.test(\n token\n )\n}\n\n/**\n * 从输入中提取所有 token\n * 按空白/逗号/分号/中英文冒号/换行/引号(中英文)/等号/%20 分割\n * 注意:URL 里的 `://` 冒号必须保留,否则会被误切。\n */\nconst extractTokens = (input: string): string[] => {\n // 用占位符保护整段 URL 中的所有冒号(含 `://` 协议冒号与 `host:port` 端口冒号),\n // 避免被英文冒号分隔符切碎。匹配范围:从 `http(s)://` 到下一个空白/引号/中文标点/逗号/分号/换行。\n const COLON_PLACEHOLDER = '__URLCOLON__'\n const protectedInput = input\n .replace(/%20/g, ' ')\n .replace(/https?:\\/\\/[^\\s,;,;:=\"“”'‘’\\n]+/gi, m => m.replace(/:/g, COLON_PLACEHOLDER))\n\n return protectedInput\n .split(/[\\n\\s,;,;::=\"“”'‘’]+/)\n .map(t => t.split(COLON_PLACEHOLDER).join(':'))\n .filter(t => t.length > 0)\n}\n\n/**\n * 根据 URL 路径检测服务类型,并返回清理后的 baseUrl\n * /messages → claude, /chat/completions → openai, /responses → responses\n */\nconst detectServiceTypeAndCleanUrl = (\n url: string\n): { serviceType: 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null; cleanedUrl: string } => {\n try {\n const cleanUrl = url.replace(/#$/, '')\n const parsed = new URL(cleanUrl)\n const path = parsed.pathname.toLowerCase()\n\n const endpointRules: Array<{ pattern: RegExp; serviceType: 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' }> = [\n { pattern: /\\/v\\d+[a-z]*\\/messages(?:\\/|$)/, serviceType: 'claude' },\n { pattern: /\\/messages(?:\\/|$)/, serviceType: 'claude' },\n { pattern: /\\/v\\d+[a-z]*\\/embeddings(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/embeddings(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/v\\d+[a-z]*\\/chat\\/completions(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/chat\\/completions(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/v\\d+[a-z]*\\/responses(?:\\/|$)/, serviceType: 'responses' },\n { pattern: /\\/responses(?:\\/|$)/, serviceType: 'responses' },\n { pattern: /\\/v\\d+[a-z]*\\/models\\/[^/]+:generatecontent(?:\\/|$)/, serviceType: 'gemini' },\n { pattern: /\\/models\\/[^/]+:generatecontent(?:\\/|$)/, serviceType: 'gemini' },\n { pattern: /\\/generatecontent(?:\\/|$)/, serviceType: 'gemini' },\n ]\n for (const rule of endpointRules) {\n const match = path.match(rule.pattern)\n if (!match || match.index === undefined) continue\n // 移除协议端点路径,保留 /v1 或 /v1beta 等版本前缀。\n parsed.pathname = path.slice(0, match.index) || '/'\n let result = parsed.toString().replace(/\\/$/, '')\n if (url.endsWith('#')) result += '#'\n return { serviceType: rule.serviceType, cleanedUrl: result }\n }\n\n const urlKey = `${parsed.origin}${path}`.replace(/\\/$/, '')\n if (parsed.hostname.toLowerCase() === 'api.githubcopilot.com') {\n return { serviceType: 'copilot', cleanedUrl: parsed.origin }\n }\n const knownClaudeUrls = new Set([\n 'https://cp.compshare.cn',\n 'https://api.kimi.com/coding',\n 'https://ark.cn-beijing.volces.com/api/coding',\n 'https://openrouter.ai/api',\n 'https://api-inference.modelscope.cn',\n 'https://api.easytransnote.com/coding',\n ])\n const knownOpenAIUrls = new Set([\n 'https://api.deepseek.com/v1',\n 'https://api.xiaomimimo.com/v1',\n 'https://token-plan-cn.xiaomimimo.com/v1',\n 'https://token-plan-sgp.xiaomimimo.com/v1',\n 'https://token-plan-ams.xiaomimimo.com/v1',\n 'https://cp.compshare.cn/v1',\n 'https://api.moonshot.cn/v1',\n 'https://api.kimi.com/coding/v1',\n 'https://open.bigmodel.cn/api/coding/paas/v4',\n 'https://open.bigmodel.cn/api/paas/v4',\n 'https://api.minimax.chat/v1',\n 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n 'https://coding.dashscope.aliyuncs.com/v1',\n 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1',\n 'https://opencode.ai/zen/v1',\n 'https://opencode.ai/zen/go/v1',\n 'https://api.lkeap.cloud.tencent.com/plan/v3',\n 'https://ark.cn-beijing.volces.com/api/coding/v3',\n 'https://qianfan.baidubce.com/v2/coding',\n 'https://runapi.co/api/v1',\n 'https://unity2.ai/v1',\n 'https://openrouter.ai/api/v1',\n 'https://api-inference.modelscope.cn/v1',\n 'https://api.easytransnote.com/coding/v1',\n ])\n if (knownClaudeUrls.has(urlKey)) {\n return { serviceType: 'claude', cleanedUrl: cleanUrl }\n }\n if (knownOpenAIUrls.has(urlKey)) {\n return { serviceType: 'openai', cleanedUrl: cleanUrl }\n }\n\n const hintText = `${parsed.hostname.toLowerCase()} ${path}`\n if (/\\b(anthropic|claude)\\b/.test(hintText) || /(^|\\/)(anthropic|claude)(?:\\/|$)/.test(path)) {\n return { serviceType: 'claude', cleanedUrl: url }\n }\n if (/\\bgemini\\b/.test(hintText) || /generativelanguage\\.googleapis\\.com$/i.test(parsed.hostname)) {\n return { serviceType: 'gemini', cleanedUrl: url }\n }\n if (/\\bresponses\\b/.test(hintText)) {\n return { serviceType: 'responses', cleanedUrl: url }\n }\n if (/\\bcopilot\\b/.test(hintText)) {\n return { serviceType: 'copilot', cleanedUrl: url }\n }\n if (/\\b(openai|chatgpt)\\b/.test(hintText)) {\n return { serviceType: 'openai', cleanedUrl: url }\n }\n\n // 剔除常见第三方面板路径,仅保留 origin 作为 baseUrl。\n const dashboardUrl = stripDashboardPathFromBaseUrl(url)\n if (dashboardUrl !== url) {\n return { serviceType: null, cleanedUrl: dashboardUrl }\n }\n } catch {\n // 忽略解析错误\n }\n return { serviceType: null, cleanedUrl: url }\n}\n\n// 保留导出以兼容可能的外部使用\nexport const detectServiceType = (url: string): 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null => {\n return detectServiceTypeAndCleanUrl(url).serviceType\n}\n\n/** Base URL 最大数量限制 */\nconst MAX_BASE_URLS = 10\n\n/**\n * 对 Base URL 列表去重(等效 URL 仅保留一条;尾部 # 视为不同语义)\n */\nexport function deduplicateBaseUrls(urls: string[], serviceType: ServiceType = ''): string[] {\n return deduplicateEquivalentBaseUrls(urls, serviceType)\n}\n\n/**\n * 解析快速输入内容,提取 URL 和 API Keys\n *\n * 支持的格式:\n * 1. 纯文本:URL 和 API Key 以空白/逗号/分号/等号分隔\n * 2. 引号包裹:从 \"xxx\" 或 'xxx' 中提取内容(支持 JSON 配置格式)\n * 3. 多 Base URL:所有符合 HTTP 链接格式的都作为 baseUrl(最多 10 个)\n */\nexport const parseQuickInput = (\n input: string,\n fallbackServiceType: ServiceType = ''\n): {\n detectedBaseUrl: string\n detectedBaseUrls: string[]\n rawBaseUrls: string[]\n detectedApiKeys: string[]\n detectedServiceType: 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null\n} => {\n const rawUrls: string[] = []\n let detectedServiceType: 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null = null\n const detectedApiKeys: string[] = []\n\n const tokens = extractTokens(input)\n\n for (const token of tokens) {\n if (isValidUrl(token)) {\n const endsWithHash = token.endsWith('#')\n let url = endsWithHash ? token.slice(0, -1) : token\n url = url.replace(/\\/$/, '')\n const fullUrl = endsWithHash ? url + '#' : url\n\n // 检测协议并清理 URL(移除端点路径)\n const { serviceType, cleanedUrl } = detectServiceTypeAndCleanUrl(fullUrl)\n\n rawUrls.push(cleanedUrl)\n // 使用第一个 URL 的服务类型\n if (!detectedServiceType) {\n detectedServiceType = serviceType\n }\n continue\n }\n\n if (isValidApiKey(token) && !detectedApiKeys.includes(token)) {\n detectedApiKeys.push(token)\n }\n }\n\n // 先去重,再限制数量,避免误拒绝等效 URL\n const deduplicatedRawBaseUrls = Array.from(new Set(rawUrls)).slice(0, MAX_BASE_URLS)\n const detectedBaseUrls = deduplicateBaseUrls(deduplicatedRawBaseUrls, detectedServiceType || fallbackServiceType).slice(0, MAX_BASE_URLS)\n\n return {\n detectedBaseUrl: detectedBaseUrls[0] || '',\n detectedBaseUrls,\n rawBaseUrls: deduplicatedRawBaseUrls,\n detectedApiKeys,\n detectedServiceType\n }\n}\n"}, "e972d82e6b4b3edf23f948edbd0db2d4c80db655": {"t": 1785809885.0314286, "n": 12075, "h": "f25e27819c1b9d72", "c": "import { deduplicateEquivalentBaseUrls, stripDashboardPathFromBaseUrl, type ServiceType } from './baseUrlSemantics'\n\n/**\n * 快速添加渠道 - 输入解析工具\n *\n * 用于识别 API Key 和 URL 格式\n */\n\n/**\n * 检测字符串是否看起来像配置键名\n * - 全大写 + 下划线分隔的多段单词:API_TIMEOUT_MS, ANTHROPIC_BASE_URL\n * - 全小写常见配置字段名:api_key, base_url, url, name, token, auth, secret, endpoint, model, type\n * (来自用户粘贴 yaml/markdown 配置时的字段名残留,而不是真实密钥)\n */\nconst looksLikeConfigKey = (token: string): boolean => {\n // 1) 全大写字母 + 下划线,且由多个单词组成(至少包含一个下划线分隔的段)\n if (/^[A-Z][A-Z0-9]*(_[A-Z][A-Z0-9]*)+$/.test(token)) {\n return true\n }\n\n // 2) 全小写、常见配置字段名(精确白名单避免误伤短前缀密钥如 hf_short / r8_abc)\n const LOWERCASE_CONFIG_KEYS = new Set([\n 'api_key',\n 'apikey',\n 'api_secret',\n 'access_key',\n 'access_token',\n 'auth_token',\n 'auth_key',\n 'secret_key',\n 'secret',\n 'token',\n 'auth',\n 'base_url',\n 'baseurl',\n 'base',\n 'url',\n 'endpoint',\n 'host',\n 'name',\n 'model',\n 'model_name',\n 'type',\n 'service_type',\n 'provider',\n 'env',\n ])\n if (LOWERCASE_CONFIG_KEYS.has(token.toLowerCase())) {\n return true\n }\n\n return false\n}\n\n/**\n * 各平台 API Key 格式的专用正则匹配\n *\n * 国际主流:\n * - OpenAI Legacy: sk-[a-zA-Z0-9]{48}\n * - OpenAI Project: sk-proj-[a-zA-Z0-9-]{100,}\n * - Anthropic: sk-ant-api03-[a-zA-Z0-9-]{80,}\n * - Google Gemini: AIza[0-9A-Za-z-_]{35}\n * - Azure OpenAI: 32位十六进制\n *\n * 新兴生态:\n * - Hugging Face: hf_[a-zA-Z0-9]{34}\n * - Groq: gsk_[a-zA-Z0-9]{52}\n * - Perplexity: pplx-[a-zA-Z0-9]{40,}\n * - Replicate: r8_[a-zA-Z0-9]+\n * - OpenRouter: sk-or-v1-[a-zA-Z0-9]{50,}\n *\n * 国内平台:\n * - DeepSeek/Moonshot/01.AI/SiliconFlow: sk-[a-zA-Z0-9]{48} (兼容 OpenAI)\n * - 智谱 AI: [a-z0-9]{32}\\.[a-z0-9]+ (id.secret 格式)\n * - 火山引擎 Ark: UUID 格式\n * - 火山引擎 IAM: AK 开头\n */\nconst ZHIPU_API_KEY_PATTERN = /^[a-zA-Z0-9]{20,}\\.[a-zA-Z0-9]{10,}$/\n\nconst PLATFORM_KEY_PATTERNS: RegExp[] = [\n // OpenAI Project Key (新格式,最长,优先匹配)\n /^sk-proj-[a-zA-Z0-9_-]{50,}$/,\n // Anthropic Claude\n /^sk-ant-api03-[a-zA-Z0-9_-]{50,}$/,\n // OpenRouter (混合大小写字母数字)\n /^sk-or-v1-[a-zA-Z0-9]{50,}$/,\n // OpenAI Legacy / DeepSeek / Moonshot / 01.AI / SiliconFlow\n /^sk-[a-zA-Z0-9]{20,}$/,\n // Google Gemini/PaLM (通常 39 字符,允许一定范围)\n /^AIza[0-9A-Za-z_-]{30,}$/,\n // Hugging Face\n /^hf_[a-zA-Z0-9]{30,}$/,\n // Groq\n /^gsk_[a-zA-Z0-9]{40,}$/,\n // Perplexity\n /^pplx-[a-zA-Z0-9]{40,}$/,\n // Replicate\n /^r8_[a-zA-Z0-9]{20,}$/,\n // 智谱 AI (id.secret 格式)\n ZHIPU_API_KEY_PATTERN,\n // 火山引擎 Ark (UUID 格式)\n /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,\n // 火山引擎 IAM AK\n /^AK[A-Z]{2,4}[a-zA-Z0-9]{20,}$/\n]\n\n/** 智谱传统 API Key 使用唯一的 id.secret 格式,可用于无 URL 时的 provider 推断。 */\nexport const isZhipuApiKey = (token: string): boolean => ZHIPU_API_KEY_PATTERN.test(token.trim())\n\n/**\n * 检测字符串是否为有效的 API Key\n *\n * 支持的格式:\n * 1. 平台特定格式(优先匹配,准确度最高)\n * 2. 通用前缀格式:xx-xxx 或 xx_xxx(如 sk-xxx, ut_xxx, api-xxx)\n * 3. JWT 格式:eyJ 开头,包含两个点分隔的 base64 段\n * 4. 长随机字符串:≥32 字符的字母数字串(必须包含字母和数字)\n * 5. 宽松兜底:常见前缀 + 任意后缀(当以上都不匹配时)\n *\n * 排除的格式:\n * - 配置键名:全大写 + 下划线分隔(如 API_TIMEOUT_MS)\n */\nexport const isValidApiKey = (token: string): boolean => {\n // 首先排除配置键名格式\n if (looksLikeConfigKey(token)) {\n return false\n }\n\n // 1. 平台特定格式匹配(最准确)\n for (const pattern of PLATFORM_KEY_PATTERNS) {\n if (pattern.test(token)) {\n return true\n }\n }\n\n // 2. 通用前缀格式(前缀 2-6 字母 + 连字符/下划线 + 至少 10 字符后缀)\n // 后缀必须包含数字或混合大小写(随机特征)\n if (/^[a-zA-Z]{2,6}[-_][a-zA-Z0-9_-]{10,}$/.test(token)) {\n const suffix = token.replace(/^[a-zA-Z]{2,6}[-_]/, '')\n const hasDigit = /\\d/.test(suffix)\n const hasMixedCase = /[a-z]/.test(suffix) && /[A-Z]/.test(suffix)\n if (hasDigit || hasMixedCase) {\n return true\n }\n }\n\n // 3. JWT 格式 (eyJ 开头,包含两个点分隔的 base64 段,总长度 >= 20)\n if (/^eyJ[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]+\\./.test(token) && token.length >= 20) {\n return true\n }\n\n // 4. 长随机字符串(≥32 字符,必须同时包含字母和数字)\n if (token.length >= 32 && /^[a-zA-Z0-9_-]+$/.test(token) && /[a-zA-Z]/.test(token) && /\\d/.test(token)) {\n return true\n }\n\n // 5. 宽松兜底:常见 API Key 前缀 + 任意后缀(至少 1 个字符)\n // 当以上严格规则都不匹配时,放松标准识别常见格式\n // 支持:sk-xxx, api-xxx, key-xxx, ut_xxx, hf_xxx, gsk_xxx 等\n if (/^(sk|api|key|ut|hf|gsk|cr|ms|r8|pplx)[-_].+$/i.test(token)) {\n return true\n }\n\n return false\n}\n\n/**\n * 检测字符串是否为有效的 URL\n *\n * 要求:\n * - 必须以 http:// 或 https:// 开头\n * - 必须包含有效域名(域名段不能以横线开头或结尾)\n * - 支持末尾 # 标记(用于跳过自动添加 /v1)\n */\nexport const isValidUrl = (token: string): boolean => {\n // 域名段不能以横线开头或结尾,支持末尾 # 或 / 或直接结束\n return /^https?:\\/\\/[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*(:\\d+)?(\\/|#|$)/i.test(\n token\n )\n}\n\n/**\n * 从输入中提取所有 token\n * 按空白/逗号/分号/中英文冒号/换行/引号(中英文)/等号/%20 分割\n * 注意:URL 里的 `://` 冒号必须保留,否则会被误切。\n */\nconst extractTokens = (input: string): string[] => {\n // 用占位符保护整段 URL 中的所有冒号(含 `://` 协议冒号与 `host:port` 端口冒号),\n // 避免被英文冒号分隔符切碎。匹配范围:从 `http(s)://` 到下一个空白/引号/中文标点/逗号/分号/换行。\n const COLON_PLACEHOLDER = '__URLCOLON__'\n const protectedInput = input\n .replace(/%20/g, ' ')\n .replace(/https?:\\/\\/[^\\s,;,;:=\"“”'‘’\\n]+/gi, m => m.replace(/:/g, COLON_PLACEHOLDER))\n\n return protectedInput\n .split(/[\\n\\s,;,;::=\"“”'‘’]+/)\n .map(t => t.split(COLON_PLACEHOLDER).join(':'))\n .filter(t => t.length > 0)\n}\n\n/**\n * 根据 URL 路径检测服务类型,并返回清理后的 baseUrl\n * /messages → claude, /chat/completions → openai, /responses → responses\n */\nconst detectServiceTypeAndCleanUrl = (\n url: string\n): { serviceType: 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null; cleanedUrl: string } => {\n try {\n const cleanUrl = url.replace(/#$/, '')\n const parsed = new URL(cleanUrl)\n const path = parsed.pathname.toLowerCase()\n\n const endpointRules: Array<{ pattern: RegExp; serviceType: 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' }> = [\n { pattern: /\\/v\\d+[a-z]*\\/messages(?:\\/|$)/, serviceType: 'claude' },\n { pattern: /\\/messages(?:\\/|$)/, serviceType: 'claude' },\n { pattern: /\\/v\\d+[a-z]*\\/embeddings(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/embeddings(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/v\\d+[a-z]*\\/chat\\/completions(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/chat\\/completions(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/v\\d+[a-z]*\\/responses(?:\\/|$)/, serviceType: 'responses' },\n { pattern: /\\/responses(?:\\/|$)/, serviceType: 'responses' },\n { pattern: /\\/v\\d+[a-z]*\\/models\\/[^/]+:generatecontent(?:\\/|$)/, serviceType: 'gemini' },\n { pattern: /\\/models\\/[^/]+:generatecontent(?:\\/|$)/, serviceType: 'gemini' },\n { pattern: /\\/generatecontent(?:\\/|$)/, serviceType: 'gemini' },\n ]\n for (const rule of endpointRules) {\n const match = path.match(rule.pattern)\n if (!match || match.index === undefined) continue\n // 移除协议端点路径,保留 /v1 或 /v1beta 等版本前缀。\n parsed.pathname = path.slice(0, match.index) || '/'\n let result = parsed.toString().replace(/\\/$/, '')\n if (url.endsWith('#')) result += '#'\n return { serviceType: rule.serviceType, cleanedUrl: result }\n }\n\n const urlKey = `${parsed.origin}${path}`.replace(/\\/$/, '')\n if (parsed.hostname.toLowerCase() === 'api.githubcopilot.com') {\n return { serviceType: 'copilot', cleanedUrl: parsed.origin }\n }\n const knownClaudeUrls = new Set([\n 'https://cp.compshare.cn',\n 'https://api.kimi.com/coding',\n 'https://ark.cn-beijing.volces.com/api/coding',\n 'https://openrouter.ai/api',\n 'https://api-inference.modelscope.cn',\n 'https://api.easytransnote.com/coding',\n ])\n const knownOpenAIUrls = new Set([\n 'https://api.deepseek.com/v1',\n 'https://api.xiaomimimo.com/v1',\n 'https://token-plan-cn.xiaomimimo.com/v1',\n 'https://token-plan-sgp.xiaomimimo.com/v1',\n 'https://token-plan-ams.xiaomimimo.com/v1',\n 'https://cp.compshare.cn/v1',\n 'https://api.moonshot.cn/v1',\n 'https://api.kimi.com/coding/v1',\n 'https://open.bigmodel.cn/api/coding/paas/v4',\n 'https://open.bigmodel.cn/api/paas/v4',\n 'https://api.minimax.chat/v1',\n 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n 'https://coding.dashscope.aliyuncs.com/v1',\n 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1',\n 'https://opencode.ai/zen/v1',\n 'https://opencode.ai/zen/go/v1',\n 'https://api.lkeap.cloud.tencent.com/plan/v3',\n 'https://ark.cn-beijing.volces.com/api/coding/v3',\n 'https://qianfan.baidubce.com/v2/coding',\n 'https://runapi.co/api/v1',\n 'https://unity2.ai/v1',\n 'https://openrouter.ai/api/v1',\n 'https://api-inference.modelscope.cn/v1',\n 'https://api.easytransnote.com/coding/v1',\n ])\n if (knownClaudeUrls.has(urlKey)) {\n return { serviceType: 'claude', cleanedUrl: cleanUrl }\n }\n if (knownOpenAIUrls.has(urlKey)) {\n return { serviceType: 'openai', cleanedUrl: cleanUrl }\n }\n\n const hintText = `${parsed.hostname.toLowerCase()} ${path}`\n if (/\\b(anthropic|claude)\\b/.test(hintText) || /(^|\\/)(anthropic|claude)(?:\\/|$)/.test(path)) {\n return { serviceType: 'claude', cleanedUrl: url }\n }\n if (/\\bgemini\\b/.test(hintText) || /generativelanguage\\.googleapis\\.com$/i.test(parsed.hostname)) {\n return { serviceType: 'gemini', cleanedUrl: url }\n }\n if (/\\bresponses\\b/.test(hintText)) {\n return { serviceType: 'responses', cleanedUrl: url }\n }\n if (/\\bcopilot\\b/.test(hintText)) {\n return { serviceType: 'copilot', cleanedUrl: url }\n }\n if (/\\b(openai|chatgpt)\\b/.test(hintText)) {\n return { serviceType: 'openai', cleanedUrl: url }\n }\n\n // 剔除常见第三方面板路径,仅保留 origin 作为 baseUrl。\n const dashboardUrl = stripDashboardPathFromBaseUrl(url)\n if (dashboardUrl !== url) {\n return { serviceType: null, cleanedUrl: dashboardUrl }\n }\n } catch {\n // 忽略解析错误\n }\n return { serviceType: null, cleanedUrl: url }\n}\n\n// 保留导出以兼容可能的外部使用\nexport const detectServiceType = (url: string): 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null => {\n return detectServiceTypeAndCleanUrl(url).serviceType\n}\n\n/** Base URL 最大数量限制 */\nconst MAX_BASE_URLS = 10\n\n/**\n * 对 Base URL 列表去重(等效 URL 仅保留一条;尾部 # 视为不同语义)\n */\nexport function deduplicateBaseUrls(urls: string[], serviceType: ServiceType = ''): string[] {\n return deduplicateEquivalentBaseUrls(urls, serviceType)\n}\n\n/**\n * 解析快速输入内容,提取 URL 和 API Keys\n *\n * 支持的格式:\n * 1. 纯文本:URL 和 API Key 以空白/逗号/分号/等号分隔\n * 2. 引号包裹:从 \"xxx\" 或 'xxx' 中提取内容(支持 JSON 配置格式)\n * 3. 多 Base URL:所有符合 HTTP 链接格式的都作为 baseUrl(最多 10 个)\n */\nexport const parseQuickInput = (\n input: string,\n fallbackServiceType: ServiceType = ''\n): {\n detectedBaseUrl: string\n detectedBaseUrls: string[]\n rawBaseUrls: string[]\n detectedApiKeys: string[]\n detectedServiceType: 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null\n} => {\n const rawUrls: string[] = []\n let detectedServiceType: 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null = null\n const detectedApiKeys: string[] = []\n\n const tokens = extractTokens(input)\n\n for (const token of tokens) {\n if (isValidUrl(token)) {\n const endsWithHash = token.endsWith('#')\n let url = endsWithHash ? token.slice(0, -1) : token\n url = url.replace(/\\/$/, '')\n const fullUrl = endsWithHash ? url + '#' : url\n\n // 检测协议并清理 URL(移除端点路径)\n const { serviceType, cleanedUrl } = detectServiceTypeAndCleanUrl(fullUrl)\n\n rawUrls.push(cleanedUrl)\n // 使用第一个 URL 的服务类型\n if (!detectedServiceType) {\n detectedServiceType = serviceType\n }\n continue\n }\n\n if (isValidApiKey(token) && !detectedApiKeys.includes(token)) {\n detectedApiKeys.push(token)\n }\n }\n\n // 先去重,再限制数量,避免误拒绝等效 URL\n const deduplicatedRawBaseUrls = Array.from(new Set(rawUrls)).slice(0, MAX_BASE_URLS)\n const detectedBaseUrls = deduplicateBaseUrls(deduplicatedRawBaseUrls, detectedServiceType || fallbackServiceType).slice(0, MAX_BASE_URLS)\n\n return {\n detectedBaseUrl: detectedBaseUrls[0] || '',\n detectedBaseUrls,\n rawBaseUrls: deduplicatedRawBaseUrls,\n detectedApiKeys,\n detectedServiceType\n }\n}\n"}, "4da6d77e15a5c2ac4bc15ce1ad1a68cfdbd2e2d0": {"t": 1785846835.1500747, "n": 12075, "h": "f25e27819c1b9d72", "c": "import { deduplicateEquivalentBaseUrls, stripDashboardPathFromBaseUrl, type ServiceType } from './baseUrlSemantics'\n\n/**\n * 快速添加渠道 - 输入解析工具\n *\n * 用于识别 API Key 和 URL 格式\n */\n\n/**\n * 检测字符串是否看起来像配置键名\n * - 全大写 + 下划线分隔的多段单词:API_TIMEOUT_MS, ANTHROPIC_BASE_URL\n * - 全小写常见配置字段名:api_key, base_url, url, name, token, auth, secret, endpoint, model, type\n * (来自用户粘贴 yaml/markdown 配置时的字段名残留,而不是真实密钥)\n */\nconst looksLikeConfigKey = (token: string): boolean => {\n // 1) 全大写字母 + 下划线,且由多个单词组成(至少包含一个下划线分隔的段)\n if (/^[A-Z][A-Z0-9]*(_[A-Z][A-Z0-9]*)+$/.test(token)) {\n return true\n }\n\n // 2) 全小写、常见配置字段名(精确白名单避免误伤短前缀密钥如 hf_short / r8_abc)\n const LOWERCASE_CONFIG_KEYS = new Set([\n 'api_key',\n 'apikey',\n 'api_secret',\n 'access_key',\n 'access_token',\n 'auth_token',\n 'auth_key',\n 'secret_key',\n 'secret',\n 'token',\n 'auth',\n 'base_url',\n 'baseurl',\n 'base',\n 'url',\n 'endpoint',\n 'host',\n 'name',\n 'model',\n 'model_name',\n 'type',\n 'service_type',\n 'provider',\n 'env',\n ])\n if (LOWERCASE_CONFIG_KEYS.has(token.toLowerCase())) {\n return true\n }\n\n return false\n}\n\n/**\n * 各平台 API Key 格式的专用正则匹配\n *\n * 国际主流:\n * - OpenAI Legacy: sk-[a-zA-Z0-9]{48}\n * - OpenAI Project: sk-proj-[a-zA-Z0-9-]{100,}\n * - Anthropic: sk-ant-api03-[a-zA-Z0-9-]{80,}\n * - Google Gemini: AIza[0-9A-Za-z-_]{35}\n * - Azure OpenAI: 32位十六进制\n *\n * 新兴生态:\n * - Hugging Face: hf_[a-zA-Z0-9]{34}\n * - Groq: gsk_[a-zA-Z0-9]{52}\n * - Perplexity: pplx-[a-zA-Z0-9]{40,}\n * - Replicate: r8_[a-zA-Z0-9]+\n * - OpenRouter: sk-or-v1-[a-zA-Z0-9]{50,}\n *\n * 国内平台:\n * - DeepSeek/Moonshot/01.AI/SiliconFlow: sk-[a-zA-Z0-9]{48} (兼容 OpenAI)\n * - 智谱 AI: [a-z0-9]{32}\\.[a-z0-9]+ (id.secret 格式)\n * - 火山引擎 Ark: UUID 格式\n * - 火山引擎 IAM: AK 开头\n */\nconst ZHIPU_API_KEY_PATTERN = /^[a-zA-Z0-9]{20,}\\.[a-zA-Z0-9]{10,}$/\n\nconst PLATFORM_KEY_PATTERNS: RegExp[] = [\n // OpenAI Project Key (新格式,最长,优先匹配)\n /^sk-proj-[a-zA-Z0-9_-]{50,}$/,\n // Anthropic Claude\n /^sk-ant-api03-[a-zA-Z0-9_-]{50,}$/,\n // OpenRouter (混合大小写字母数字)\n /^sk-or-v1-[a-zA-Z0-9]{50,}$/,\n // OpenAI Legacy / DeepSeek / Moonshot / 01.AI / SiliconFlow\n /^sk-[a-zA-Z0-9]{20,}$/,\n // Google Gemini/PaLM (通常 39 字符,允许一定范围)\n /^AIza[0-9A-Za-z_-]{30,}$/,\n // Hugging Face\n /^hf_[a-zA-Z0-9]{30,}$/,\n // Groq\n /^gsk_[a-zA-Z0-9]{40,}$/,\n // Perplexity\n /^pplx-[a-zA-Z0-9]{40,}$/,\n // Replicate\n /^r8_[a-zA-Z0-9]{20,}$/,\n // 智谱 AI (id.secret 格式)\n ZHIPU_API_KEY_PATTERN,\n // 火山引擎 Ark (UUID 格式)\n /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,\n // 火山引擎 IAM AK\n /^AK[A-Z]{2,4}[a-zA-Z0-9]{20,}$/\n]\n\n/** 智谱传统 API Key 使用唯一的 id.secret 格式,可用于无 URL 时的 provider 推断。 */\nexport const isZhipuApiKey = (token: string): boolean => ZHIPU_API_KEY_PATTERN.test(token.trim())\n\n/**\n * 检测字符串是否为有效的 API Key\n *\n * 支持的格式:\n * 1. 平台特定格式(优先匹配,准确度最高)\n * 2. 通用前缀格式:xx-xxx 或 xx_xxx(如 sk-xxx, ut_xxx, api-xxx)\n * 3. JWT 格式:eyJ 开头,包含两个点分隔的 base64 段\n * 4. 长随机字符串:≥32 字符的字母数字串(必须包含字母和数字)\n * 5. 宽松兜底:常见前缀 + 任意后缀(当以上都不匹配时)\n *\n * 排除的格式:\n * - 配置键名:全大写 + 下划线分隔(如 API_TIMEOUT_MS)\n */\nexport const isValidApiKey = (token: string): boolean => {\n // 首先排除配置键名格式\n if (looksLikeConfigKey(token)) {\n return false\n }\n\n // 1. 平台特定格式匹配(最准确)\n for (const pattern of PLATFORM_KEY_PATTERNS) {\n if (pattern.test(token)) {\n return true\n }\n }\n\n // 2. 通用前缀格式(前缀 2-6 字母 + 连字符/下划线 + 至少 10 字符后缀)\n // 后缀必须包含数字或混合大小写(随机特征)\n if (/^[a-zA-Z]{2,6}[-_][a-zA-Z0-9_-]{10,}$/.test(token)) {\n const suffix = token.replace(/^[a-zA-Z]{2,6}[-_]/, '')\n const hasDigit = /\\d/.test(suffix)\n const hasMixedCase = /[a-z]/.test(suffix) && /[A-Z]/.test(suffix)\n if (hasDigit || hasMixedCase) {\n return true\n }\n }\n\n // 3. JWT 格式 (eyJ 开头,包含两个点分隔的 base64 段,总长度 >= 20)\n if (/^eyJ[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]+\\./.test(token) && token.length >= 20) {\n return true\n }\n\n // 4. 长随机字符串(≥32 字符,必须同时包含字母和数字)\n if (token.length >= 32 && /^[a-zA-Z0-9_-]+$/.test(token) && /[a-zA-Z]/.test(token) && /\\d/.test(token)) {\n return true\n }\n\n // 5. 宽松兜底:常见 API Key 前缀 + 任意后缀(至少 1 个字符)\n // 当以上严格规则都不匹配时,放松标准识别常见格式\n // 支持:sk-xxx, api-xxx, key-xxx, ut_xxx, hf_xxx, gsk_xxx 等\n if (/^(sk|api|key|ut|hf|gsk|cr|ms|r8|pplx)[-_].+$/i.test(token)) {\n return true\n }\n\n return false\n}\n\n/**\n * 检测字符串是否为有效的 URL\n *\n * 要求:\n * - 必须以 http:// 或 https:// 开头\n * - 必须包含有效域名(域名段不能以横线开头或结尾)\n * - 支持末尾 # 标记(用于跳过自动添加 /v1)\n */\nexport const isValidUrl = (token: string): boolean => {\n // 域名段不能以横线开头或结尾,支持末尾 # 或 / 或直接结束\n return /^https?:\\/\\/[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*(:\\d+)?(\\/|#|$)/i.test(\n token\n )\n}\n\n/**\n * 从输入中提取所有 token\n * 按空白/逗号/分号/中英文冒号/换行/引号(中英文)/等号/%20 分割\n * 注意:URL 里的 `://` 冒号必须保留,否则会被误切。\n */\nconst extractTokens = (input: string): string[] => {\n // 用占位符保护整段 URL 中的所有冒号(含 `://` 协议冒号与 `host:port` 端口冒号),\n // 避免被英文冒号分隔符切碎。匹配范围:从 `http(s)://` 到下一个空白/引号/中文标点/逗号/分号/换行。\n const COLON_PLACEHOLDER = '__URLCOLON__'\n const protectedInput = input\n .replace(/%20/g, ' ')\n .replace(/https?:\\/\\/[^\\s,;,;:=\"“”'‘’\\n]+/gi, m => m.replace(/:/g, COLON_PLACEHOLDER))\n\n return protectedInput\n .split(/[\\n\\s,;,;::=\"“”'‘’]+/)\n .map(t => t.split(COLON_PLACEHOLDER).join(':'))\n .filter(t => t.length > 0)\n}\n\n/**\n * 根据 URL 路径检测服务类型,并返回清理后的 baseUrl\n * /messages → claude, /chat/completions → openai, /responses → responses\n */\nconst detectServiceTypeAndCleanUrl = (\n url: string\n): { serviceType: 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null; cleanedUrl: string } => {\n try {\n const cleanUrl = url.replace(/#$/, '')\n const parsed = new URL(cleanUrl)\n const path = parsed.pathname.toLowerCase()\n\n const endpointRules: Array<{ pattern: RegExp; serviceType: 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' }> = [\n { pattern: /\\/v\\d+[a-z]*\\/messages(?:\\/|$)/, serviceType: 'claude' },\n { pattern: /\\/messages(?:\\/|$)/, serviceType: 'claude' },\n { pattern: /\\/v\\d+[a-z]*\\/embeddings(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/embeddings(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/v\\d+[a-z]*\\/chat\\/completions(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/chat\\/completions(?:\\/|$)/, serviceType: 'openai' },\n { pattern: /\\/v\\d+[a-z]*\\/responses(?:\\/|$)/, serviceType: 'responses' },\n { pattern: /\\/responses(?:\\/|$)/, serviceType: 'responses' },\n { pattern: /\\/v\\d+[a-z]*\\/models\\/[^/]+:generatecontent(?:\\/|$)/, serviceType: 'gemini' },\n { pattern: /\\/models\\/[^/]+:generatecontent(?:\\/|$)/, serviceType: 'gemini' },\n { pattern: /\\/generatecontent(?:\\/|$)/, serviceType: 'gemini' },\n ]\n for (const rule of endpointRules) {\n const match = path.match(rule.pattern)\n if (!match || match.index === undefined) continue\n // 移除协议端点路径,保留 /v1 或 /v1beta 等版本前缀。\n parsed.pathname = path.slice(0, match.index) || '/'\n let result = parsed.toString().replace(/\\/$/, '')\n if (url.endsWith('#')) result += '#'\n return { serviceType: rule.serviceType, cleanedUrl: result }\n }\n\n const urlKey = `${parsed.origin}${path}`.replace(/\\/$/, '')\n if (parsed.hostname.toLowerCase() === 'api.githubcopilot.com') {\n return { serviceType: 'copilot', cleanedUrl: parsed.origin }\n }\n const knownClaudeUrls = new Set([\n 'https://cp.compshare.cn',\n 'https://api.kimi.com/coding',\n 'https://ark.cn-beijing.volces.com/api/coding',\n 'https://openrouter.ai/api',\n 'https://api-inference.modelscope.cn',\n 'https://api.easytransnote.com/coding',\n ])\n const knownOpenAIUrls = new Set([\n 'https://api.deepseek.com/v1',\n 'https://api.xiaomimimo.com/v1',\n 'https://token-plan-cn.xiaomimimo.com/v1',\n 'https://token-plan-sgp.xiaomimimo.com/v1',\n 'https://token-plan-ams.xiaomimimo.com/v1',\n 'https://cp.compshare.cn/v1',\n 'https://api.moonshot.cn/v1',\n 'https://api.kimi.com/coding/v1',\n 'https://open.bigmodel.cn/api/coding/paas/v4',\n 'https://open.bigmodel.cn/api/paas/v4',\n 'https://api.minimax.chat/v1',\n 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n 'https://coding.dashscope.aliyuncs.com/v1',\n 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1',\n 'https://opencode.ai/zen/v1',\n 'https://opencode.ai/zen/go/v1',\n 'https://api.lkeap.cloud.tencent.com/plan/v3',\n 'https://ark.cn-beijing.volces.com/api/coding/v3',\n 'https://qianfan.baidubce.com/v2/coding',\n 'https://runapi.co/api/v1',\n 'https://unity2.ai/v1',\n 'https://openrouter.ai/api/v1',\n 'https://api-inference.modelscope.cn/v1',\n 'https://api.easytransnote.com/coding/v1',\n ])\n if (knownClaudeUrls.has(urlKey)) {\n return { serviceType: 'claude', cleanedUrl: cleanUrl }\n }\n if (knownOpenAIUrls.has(urlKey)) {\n return { serviceType: 'openai', cleanedUrl: cleanUrl }\n }\n\n const hintText = `${parsed.hostname.toLowerCase()} ${path}`\n if (/\\b(anthropic|claude)\\b/.test(hintText) || /(^|\\/)(anthropic|claude)(?:\\/|$)/.test(path)) {\n return { serviceType: 'claude', cleanedUrl: url }\n }\n if (/\\bgemini\\b/.test(hintText) || /generativelanguage\\.googleapis\\.com$/i.test(parsed.hostname)) {\n return { serviceType: 'gemini', cleanedUrl: url }\n }\n if (/\\bresponses\\b/.test(hintText)) {\n return { serviceType: 'responses', cleanedUrl: url }\n }\n if (/\\bcopilot\\b/.test(hintText)) {\n return { serviceType: 'copilot', cleanedUrl: url }\n }\n if (/\\b(openai|chatgpt)\\b/.test(hintText)) {\n return { serviceType: 'openai', cleanedUrl: url }\n }\n\n // 剔除常见第三方面板路径,仅保留 origin 作为 baseUrl。\n const dashboardUrl = stripDashboardPathFromBaseUrl(url)\n if (dashboardUrl !== url) {\n return { serviceType: null, cleanedUrl: dashboardUrl }\n }\n } catch {\n // 忽略解析错误\n }\n return { serviceType: null, cleanedUrl: url }\n}\n\n// 保留导出以兼容可能的外部使用\nexport const detectServiceType = (url: string): 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null => {\n return detectServiceTypeAndCleanUrl(url).serviceType\n}\n\n/** Base URL 最大数量限制 */\nconst MAX_BASE_URLS = 10\n\n/**\n * 对 Base URL 列表去重(等效 URL 仅保留一条;尾部 # 视为不同语义)\n */\nexport function deduplicateBaseUrls(urls: string[], serviceType: ServiceType = ''): string[] {\n return deduplicateEquivalentBaseUrls(urls, serviceType)\n}\n\n/**\n * 解析快速输入内容,提取 URL 和 API Keys\n *\n * 支持的格式:\n * 1. 纯文本:URL 和 API Key 以空白/逗号/分号/等号分隔\n * 2. 引号包裹:从 \"xxx\" 或 'xxx' 中提取内容(支持 JSON 配置格式)\n * 3. 多 Base URL:所有符合 HTTP 链接格式的都作为 baseUrl(最多 10 个)\n */\nexport const parseQuickInput = (\n input: string,\n fallbackServiceType: ServiceType = ''\n): {\n detectedBaseUrl: string\n detectedBaseUrls: string[]\n rawBaseUrls: string[]\n detectedApiKeys: string[]\n detectedServiceType: 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null\n} => {\n const rawUrls: string[] = []\n let detectedServiceType: 'openai' | 'gemini' | 'claude' | 'responses' | 'copilot' | null = null\n const detectedApiKeys: string[] = []\n\n const tokens = extractTokens(input)\n\n for (const token of tokens) {\n if (isValidUrl(token)) {\n const endsWithHash = token.endsWith('#')\n let url = endsWithHash ? token.slice(0, -1) : token\n url = url.replace(/\\/$/, '')\n const fullUrl = endsWithHash ? url + '#' : url\n\n // 检测协议并清理 URL(移除端点路径)\n const { serviceType, cleanedUrl } = detectServiceTypeAndCleanUrl(fullUrl)\n\n rawUrls.push(cleanedUrl)\n // 使用第一个 URL 的服务类型\n if (!detectedServiceType) {\n detectedServiceType = serviceType\n }\n continue\n }\n\n if (isValidApiKey(token) && !detectedApiKeys.includes(token)) {\n detectedApiKeys.push(token)\n }\n }\n\n // 先去重,再限制数量,避免误拒绝等效 URL\n const deduplicatedRawBaseUrls = Array.from(new Set(rawUrls)).slice(0, MAX_BASE_URLS)\n const detectedBaseUrls = deduplicateBaseUrls(deduplicatedRawBaseUrls, detectedServiceType || fallbackServiceType).slice(0, MAX_BASE_URLS)\n\n return {\n detectedBaseUrl: detectedBaseUrls[0] || '',\n detectedBaseUrls,\n rawBaseUrls: deduplicatedRawBaseUrls,\n detectedApiKeys,\n detectedServiceType\n }\n}\n"}}, "CHANGELOG.md": {"ebe33699466c4c93f605d8b16e5eb0567c127bd5": {"t": 1785674294.293568, "n": 114679, "h": "734953b7098f3dc6", "c": "## [Unreleased]\n\n### 新增\n\n- **订阅中心与密钥管理体验** - 订阅中心改为渠道中心式 Provider 快捷接入,支持一键添加 GitHub Copilot、new-api 与手动订阅;三家赞助商展示真实 Logo/徽标并移除 Unity2.ai;API Key 支持手动暂停/恢复,模型覆盖按 Key 集合归组,火山折叠摘要及 Kimi/Compshare/MiniMax 余量改为统一五档阈值着色,Kimi 同时展示订阅总用量、赠送额度和加油包余额\n- **Autopilot Trace v2 契约与脱敏边界** - 新增 TraceDetailV2/TraceSummary/SchedulerDecisionSummary/EndpointAttemptSummary DTO,ComparisonStatus 三态枚举(matched/mismatched/uncompared)替代裸 bool,SanitizeForPersistence/SanitizeForResponse 双边界脱敏,crypto/rand 碰撞安全 TraceUIDv2,策略指纹(排除比例/seed),稳定哈希分桶,v1→v2 读适配器;新增 40+ L1 表驱动测试\n- **SQLite v6 迁移、持久化与清理** - autopilot schema v5→v6 幂等迁移;trace 表新增 schema_version/trace_revision/request_correlation_id/release_id/policy_fingerprint/persistence_class/details_json 列;窗口表重建主键增加 release/policy/cohort 维度和 compared/matched/mismatch/uncompared 计数;safety event 增加 release/policy 字段;in-flight 索引和异常提升落盘;有界异步 writer(512 队列/128 终态预留/64KiB 上限/250ms deadline/1s drain);7 天 trace + 30 天窗口清理策略\n- **Trace 关联接入请求生命周期** - 入口生成 requestCorrelationId 写入 gin context;选择渠道后写入 AutopilotTraceUID;ChannelLog 新增 RequestCorrelationID/AutopilotTraceUID 字段和 ChannelLogOption;BuildPlan 写入 dry-run trace(必落盘);NormalizeSelectionTrace 将 SelectionTrace 规范化为安全摘要并 AttachSchedulerDecision;AppendEndpointAttempt 有序/容量受限/乱序合并的 attempt 摘要\n- **endpoint attempt 摘要接入实际请求链路** - SetAttemptRecorderHook + recordAttemptCompleted hook 模式;upstream_failover 在 CreatePendingLog 后记 \"started\",成功/错误时记 \"completed\";main.go 注册 hook 连接 TraceStore;fail-open 观测失败不影响代理请求\n- **受控发布与自动降级** - active 模式加入配置枚举/归一化/IsAutopilotActive;RolloutPercent/ControlPercent/ReleaseID/RolloutSeed 配置字段和 validateRollout 校验;ReleaseController 集中管理状态迁移(逐级晋升/降级随时允许)、稳定分桶、EvaluateAndApplyRegression 三窗口回归降级、SafetyOverride 安全覆盖;接入 SmartRouter 请求路径(入口冻结 RoutingReleaseSnapshot);release/policy/cohort 隔离聚合\n- **只读 Trace API 与前端详情** - GET /api/autopilot/traces/:traceUid 详情端点(404/503/partial 契约、2s 查询 deadline);列表 API 改为 TraceSummary 输出,支持 release/cohort/mode 过滤和游标分页;统计新增三态比较计数;前端 AutopilotTraceDetailDialog 决策→Scheduler→尝试→终态时间线;AutopilotTraceTable 适配 TraceSummary;ChannelLogsDialog 跳转入口\n- **分层测试(L1-L5)** - L1 纯函数 40+;L2 SmartRouter+TraceStore 集成 8;L3 SQLite 生命周期 12(重启还原/坏 JSON/过期清理/迁移幂等/脱敏);L4 HTTP 契约 9(列表/详情/统计/404/脱敏/分页);L5 真实上游 smoke 2(默认 t.Skip);SSE golden 回归 6(帧顺序/trace 不改输出/取消/脱敏)\n- **Autopilot 模型与思考强度联合路由** - 自动路由的决策单元从「模型」提升为「模型 + 思考强度」的原子组合,解决模型被改写为异构上游后仍沿用客户端原档位、落在从未评测过的能力点上的问题。新增 `ResolvedRouteTarget` 作为贯穿全链路的唯一决策载体;`NormalizeEffortLevel` 统一证据侧/能力侧/请求侧的厂商档位命名,`IsUnpinnedEffort` 使 `default` 类未固定档位证据只能作降级 fallback 而非冒充精确匹配;`rankEligibleModels` 按 `SupportedEffortLevels × PerTaskClass` 展开 model×effort 候选,`betterRankedModel` 加入「够用即止」平手规则防档位膨胀,`filterEffortFloor` 按任务类下界过滤且全被过滤时 fail-open;`upstream_failover` 原子改写 model + 思考参数(任一步失败整体放弃),含 `adaptive_only` 守卫与 Gemini `thinkingConfig.thinkingLevel` 原生注入路径;`ManualRoutingIntent` 新增可选 `Effort` 字段(未知值校验拒绝,客户端显式 off/none 优先于意图锁定);`ChannelLog` 新增 `effortDecisionSource`/`effortClampedByClient`(仅在客户端上限实际生效时置位)与 Trace `effort` 维度;`PreferAutoOverManual` 开关支持手工映射优先级反转(默认关闭,可零代码回滚),新增 `GET /health-center/profile-coverage` 只读画像覆盖率诊断作为手工表退役前置门槛\n- **火山 Agent Plan AFP 成本感知路由** - 新增火山 AFP 政策目录与纯计算器、套餐作用域解析与可比较性判断、AFP 请求快照与候选级成本证据;AFP 折扣接入 SavingsScore 参与评分并写入 trace,compshare 单次减扣同样纳入;新增 `frontierRoutingEnabled` / `afpCostRoutingEnabled` 两个正交开关,AFP 成本路由默认开启\n- **确定性 Pareto 分层与三倾向候选阶梯** - 候选排序改为确定性 Pareto 分层,输出成本/质量/均衡三倾向阶梯;模型候选排序重排并接入 CostPreference\n- **渠道预检异步化与后台 discovery 断点续传** - 渠道预检不再阻塞添加流程,后台 discovery 支持断点续传\n- **多协议自动发现补全** - 补全多协议自动发现并自动启用已发现协议路由;自动发现改从 `apiKeyConfigs` 取密钥;协议探测改为逐模型验证;快速探活改为多模型候选策略(含回退测试);自定义协议渠道构造统一\n- **发现 RPM 应用与账号级 429 scoped cooldown** - 接线自动发现出的 RPM 到运行态限速,429 冷却收敛到账号级作用域\n- **连接耗时采集驱动 SpeedTier** - 新增连接建立耗时指标并参与速度分级\n- **弃用参数自动发现与记忆落盘** - 上游不支持的参数按渠道-Key-模型维度自动发现并剥离,记忆落盘后重启免重新探测\n- **渠道兼容性开关统一自学习** - 六项兼容性开关(`NormalizeNonstandardChatRoles`/`StripImageGenerationTool`/`CodexNativeToolPassthrough`/`StripEmptyTextBlocks`/`PassbackReasoningContent`/`PassbackThinkingBlocks`)从「用户手工勾选的静态布尔」彻底收归「运行时按渠道-Key-模型自动学习并持久化的能力事实」:字段已从 `UpstreamConfig`/`UpstreamUpdate` 整体删除,管理面板不再提供编辑入口,渠道更新接口不再接受手工写入,用户此后无需手工干预。新增 `ChannelCompatCache`(落盘 `.config/channel_compat.json`,TTL 24h,键 `channelUID:keyHash:model`)统一记忆六项兼容能力,最终生效值按 `学习结论 > 历史种子 > 静态默认` 合成。老用户配置文件里残留的历史手工值不代表可信的长期事实(可能当初就设错,也可能上游后来有了新情况),因此启动时 `migrateManualCompatSwitchesToSeeds` 从磁盘原始 JSON 读出历史值,一次性降级为渠道级、TTL 14 天的一次性低置信度种子(`CompatSeeds`):迁移当下不改变可感知行为,但学到真实结论后立即让位,过期后也不再参与判断;种子 TTL 与学习记忆 TTL 分开存储(14 天 vs 24 小时),避免混淆。该迁移按数组下标把原始 JSON 对到当前渠道,因此必须排在 `mergeManagedProviderAccounts` 之前——后者会把同 provider 的多账号渠道合并成一条(数组变短重排),顺序颠倒会让种子写到错误渠道或随被合并渠道丢失;`mergeManagedProviderAccounts` 同时补上了合并时携带 `CompatSeeds`,避免被合并掉的渠道的历史证据丢失。`Clone()` 一并深拷贝 `LearnedCompatTraits`,避免克隆已注入结论的 upstream 时共享 map。按信号强度分三条学习路径:有硬 400 信号的走 `CompatTraitFromError` 错误驱动(识别后同 Key 立即重试,改写在下一轮注入本次请求专属的 `LearnedCompatTraits`),其中 Codex `/v1/responses` 打到不支持 `developer` role 的上游时新增 `DowngradeDeveloperRoleToSystem` 窄改写(降 `system` 而非 `user`,保留开发者指令语义);仅能靠响应形态判断的 `passbackReasoningContent`/`stripEmptyTextBlocks` 复用渠道诊断探针,改为首次遇到该组合时 singleflight 后台自动探测一次,替代原先要用户手工点按钮;图片生成受限从「直接拉黑 (Key,模型)」改为「先学习剥离图片工具并重试,剥离后仍失败才回落拉黑」。所有识别规则采用双条件门控(正则命中 + 请求侧确实携带对应结构),且 429/5xx/超时一律不参与兼容性学习,避免把容量问题学成能力缺失。`RuntimeUpstreamForAutoManagedProvider` 连种子一并清空,自动托管渠道的兼容性完全由厂商原生默认加运行时学习决定\n- **Kimi 参数硬约束迁入 model-registry 刷新链路** - Kimi K3/K2.7-code/K2.6 的 temperature/top_p/n 固定值、tool_choice/thinking 约束不再是本地硬编码前缀表,改为 `UpstreamModelCapability.ParamConstraints`,随 `shared/model-registry/ccx_model_registry.json` 走 presetstore 的定期刷新链路发布;运营者核对厂商文档后更新 JSON 即可让线上实例在下一个轮询周期生效,不需要重新编译发版\n- **Kimi 按量模型参数硬约束前置规避** - `ApplyKnownParamConstraints` 按厂商文档在发送前规避已知约束,无需先失败一次:`kimi-k3`/`kimi-k2.7-code` 系列/`kimi-k2.6` 的 `temperature`/`top_p`/`n`/`presence_penalty`/`frequency_penalty` 为固定值,传入即 `invalid_request_error`,故直接剥离(上游用自身默认值,语义无损);`kimi-k2.6`/`kimi-k2.7-code` 不支持 `tool_choice: \"required\"`,降级为 `auto`;`kimi-k2.7-code` 的 `thinking` 仅接受 `{\"type\":\"enabled\",\"keep\":\"all\"}`,非法配置归一化为该值。`moonshot-v1` 系列的可改参数不受影响\n- **注册 Claude Opus 5** - 新增 Claude Opus 5 模型注册并支持 4.8 点号别名\n- **模型能力基准数据源扩展** - 接入 Artificial Analysis 数据源并纳入 opus-5;benchlm 改用官方 `models.json` 取代对比页抓取;benchmark-update 增加页面变更检测缓存层;补全 DEEPSWE/BENCHLM/DRADAR 白名单 CCX 模型映射与 kimi-k3、grok-4.5、muse-spark-1.1 等模型,DEEPSWE cohort 扩展至 17 模型;刷新 model registry 至 20260726\n- **trace 与账号信息可读性** - trace 候选渠道展示渠道名与 Key 掩码;账号列表返回脱敏 access token;`UsageMeter-Record` 日志补充渠道/接口/状态上下文\n- **auto 模式 SLO 回归保护可配置** - SLO 回归自动降级改为开关控制\n- **模型清单展示改进** - 共有/协议专属模型少于 10 个时直接展开列出,其余默认两行、仅实际溢出时显示展开\n- **火山套餐 Key 保活 per-key 路由与共享探针** - `config.BaseURLsForKey` 让已绑定端点的 Key 只在自己端点探测,不参与渠道级 BaseURL 笛卡尔积;新增 `internal/upstreamprobe` 共享火山 Agent/Coding Plan 数据面探针(autopilot 验证与 healthcheck 保活共用,避免请求特征漂移);`L1Response.RealCallVerified` 标记真实调用,火山 L1 成功后同周期跳过等价 L2 避免重复消耗额度;L2 探针副本覆盖绑定 BaseURL,recordFailure 归因到 Key 实际绑定端点\n- **渠道级实测上下文上限自学习** - 模型注册表登记的是模型公开窗口,个别渠道对某个模型的实际窗口更短(中转商自行截断、上游按套餐限制),长上下文请求会反复吃 400 `context_too_large`。新增按 渠道-Key-模型 维度记忆实测上限的数值型能力覆盖(`config.ContextLimitState`,复用 `ChannelCompatCache` 键空间/24h TTL/落盘,但独立于布尔型 `CompatTrait` 存储):`ContextLimitFromError` 从 400/422 报错识别超限信号,上游明确声明窗口值时直接采信,仅表示「太长」时按被拒请求量的 7/8 反推保守上界,并排除 `max_tokens`/请求体过大/图片尺寸/配额等相邻错误避免误判;合成规则「宁小勿大」多次学习取最小值,只在遇到更严格证据时收紧,放宽只能靠 TTL 过期重学;`SmartRouter.buildChannelEntry` 取 `min(注册表窗口, 实测上限)` 让上下文硬约束按真实容量判断,路由发生在选定 Key 之前故取该渠道-模型在所有已知 Key 上的最小值。上下文上限按自身 `LearnedAt` 独立计算 TTL(不共用 `entry.DetectedAt`,否则 trait 命中刷新会让实测上限被无限续期);无记忆时 fail-open,反推上界低于 4096 不采信。兼容性记忆单例上移为 `config.SharedChannelCompatCache`,供写入方 handlers 与读取方 autopilot 共享\n\n### 修复\n\n- **兼容性自学习块位于 failover 判断之后导致生产环境从未生效** - `upstream_failover.go` 的弃用参数自适应与渠道兼容性自学习(developer role 降级、Codex 工具剥离等)两个学习块原本写在 `if shouldFailover` 分支之后。而错误分类以「大多数非 2xx 都 failover」为默认策略,未命中已知不可重试关键词的 400/422 会被判定为 `shouldFailover=true` 并提前 `continue`,导致这两个学习块在默认配置下几乎从未被执行到——只有极少数恰好命中窄关键词表的报错才能落到它们。现已移至 `shouldFailover` 判断之前,与上下文上限自学习块位置一致;新增用真实 `TryUpstreamWithAllKeys` 驱动的集成测试钉住「上游 422 → 学习 → 同 Key 重试成功」完整闭环\n- **422 兼容性学习分支不可达** - 兼容性自学习块声称覆盖 400/422,但被嵌套在外层 `resp.StatusCode == 400` 守卫内,422 分支实际永远不可达(部分上游用 422 表达请求结构不受支持);改为与弃用参数块平级的兄弟块——弃用参数识别只针对 400,兼容性学习覆盖 400 与 422,二者条件不同不应嵌套\n- **Chat 入口 Gemini 上游响应被误判为空** - `handleSuccess` 的 `default` 分支承接 gemini 等没有专属 case 的透传上游类型,却统一使用只认识 OpenAI `choices[].message` 结构的 `IsChatResponseEmpty` 判空;对 Gemini 的 `candidates` 结构,`respMap[\"choices\"]` 断言必然失败而被判定为空,导致所有合法 Gemini 响应都误触发 failover、最终以 503 收尾。改为按 `candidates` 字段(Gemini 专属、非 omitempty)区分 schema 后走 `IsGeminiResponseEmpty`;不用 `usageMetadata` 判断是因为该字段为 omitempty,缺省时会误判回 OpenAI 形态\n- **effort 注入形态误判与三处死代码** - 自动托管渠道被 `RuntimeUpstreamForAutoManagedProvider` 清空 `ReasoningParamStyle` 后,`effortInjectionStyle` 一律回落 `reasoning` 对象形式,导致 Claude messages 渠道被写入 `reasoning.effort`、chat 渠道被写成对象,上游均不识别而静默丢弃 effort;改为按渠道类型推导原生形态(messages→`thinking`,chat→`reasoning_effort`)。同时修复三处「代码存在但调用链不通」的死代码:`CapabilityFloor.EffortFloor` 从未被赋值致 `filterEffortFloor` 恒直接返回,改为在 `rankEligibleModels` 内按 `ReasoningEffortConfig.PerTaskClass` 推导;`resolveRelativeBenchmarkEvidence` 的 `targetEffort` 恒传空串致 `(domain, effort)` 精确匹配与置信度折算永不执行,新增 `ResolveDomainStrengthForEffort` 并在候选展开处按变体实际档位取证据;`extractClientEffort` 协议无关全字段扫描与按渠道分支的 `ExtractClientEffortExplicit` 对跨协议字段判定相反,导致手动意图 effort 被静默屏蔽而日志记录未钳位,删除前者统一走后者。另补齐 messages 分支的 `thinking.effort` 提取(此前 `type=enabled` 只返回 `enabled` 占位而非真实档位)\n- **精确模型命中绕过 effort 决策** - `ResolveModel` 的精确/等价模型命中路径直接返回,从未调用 `resolveEffortVariants`,导致请求模型能在渠道内精确匹配时(最常见路径)手动意图锁定档位与自动展开全部失效,仅跨模型替换才走 effort 逻辑;抽出 `resolveSingleProfileEffort` 在两处分支补齐,语义与排序路径一致(意图锁定优先,否则按配置展开取最低档)\n- **effort 钳位语义与 Gemini 档位提取** - 修正 effort 钳位语义并在 failover 时重置标记;Gemini effort 提取取真实档位而非占位值,`targetByUID` 写入条件统一;`ResolvedRouteTarget` 定义与 `upstream_failover` 旧 API 保留兼容 fallback\n- **火山套餐 Key 识别与恢复** - 优先识别火山 Agent Plan 的 Kimi K3;按火山额度重置时间恢复被拉黑的 Key;`ListArkAgentPlanModel` 签名 scope 从 `ark_stg` 改回 `ark`\n- **渠道列表与诊断展示错位** - 渠道列表第二个渠道不再错显首个渠道的请求数与缓存率;渠道排序不再随 Key 拉黑变动;重新发现协议后同步刷新渠道列表;自定义渠道协议发现结果正常展示;路由过滤原因与诊断面板改用渠道名和热门模型替代 `ch_xxx` 内部 ID;Key 与健康状态同步修复\n- **驾驶舱空白** - 意图列表 nil slice 被序列化成 `null` 导致驾驶舱整页空白\n- **system 角色归一化破坏缓存边界** - providers 归一化 system 角色时保留 `cache_control` block 边界,避免打断上游 prompt 缓存\n- **billing header 域名判断忽略端口号** - 带端口的自建地址不再被误判为非官方域名,新建渠道默认值跟随 baseUrl\n- **保活验证失败无痕迹** - healthcheck 保活验证失败写入渠道日志\n- **连接延迟慢速判定误报** - 样本数不足 20 时 P95 退化为最大值导致误判慢速,新增最小样本门槛\n- **new-api 地址与图标注册** - new-api 地址自动剥离控制台路径;补齐 `mdi-github`/`mdi-plus-circle`/`mdiPause`/`timer-alert-outline`/信息提示轮廓等缺失图标注册;简化延迟告警芯片文案\n- **基准与预置数据修正** - litellm 映射补齐 26 个模型定价并去重 Kimi 会员条目;AA evidence 补 `taskCount` 修复 TS 类型校验;presetstore 支持多量纲基准原始值;区分 kimi-k3 的 256k/1m 变体上下文;清理渠道配置中过时的 grok 精确模型映射\n- **RoutingReleaseSnapshot 字段缺失** - 补全 `RolloutSeed`/`ControlPercent`,避免灰度分桶快照失真\n- **火山套餐 Key 跨套餐误探测** - 混合套餐渠道中 Agent Plan Key 不再被首地址 `/api/coding` 误打导致 auth_failed 误拉黑;火山官方套餐入口不再调用通用 `/v1/models`(套餐 Key 无法通过该接口探测),改用专用探针 + 内置 manifest 模型清单;400/404/500/网络错误不升级为认证失败\n- **渠道全部 Key 禁用时无恢复入口** - `hasOnlyDisabledChannelApiKeys` 判定(可用=0 且禁用>0)下渠道行直接显示恢复按钮,一次点击恢复全部禁用 Key,无需先暂停;active 渠道跳过冗余 `setStatus(active)`,直接 resume 并刷新\n- **非官方域名默认剔除 billing header** - `stripBillingHeader` 未显式设置时按渠道 BaseURL 推断默认值:仅当渠道所有地址均为 Anthropic 官方 API(`api.anthropic.com`)时保留计费 header,其余第三方渠道默认剔除;此前默认关闭,导致 Claude Code 每次请求变化的 `cch=` 计费 nonce 打穿第三方上游的 prompt 缓存前缀,造成缓存命中率异常下降\n\n### 重构\n\n- **移除 Fuzzy 模式开关,统一为单一 failover 策略(BREAKING)** - Fuzzy 模式早已是唯一实际使用的默认值,Normal 模式的精确状态码分类分支已无存在意义,保留两套逻辑徒增维护成本。删除 `FuzzyModeEnabled` 配置字段、`GetFuzzyModeEnabled`/`SetFuzzyModeEnabled`、`GET/PUT /api/settings/fuzzy-mode` 接口及其配置迁移逻辑;`shouldRetryWithNextKeyNormal` 与 `classifyByStatusCode` 整体删除,`shouldRetryWithNextKeyFuzzy` 重命名为 `shouldRetryWithNextKey` 成为唯一实现;`HandleAllChannelsFailed`/`HandleAllKeysFailed` 不再接收 `fuzzyMode` 参数,统一返回通用 503;各协议 `handleSuccess` 的空响应拦截(`IsClaudeResponseEmpty` 等)不再受 `fuzzyMode` 门控而无条件生效;Web 与桌面前端一并移除工具栏开关、stores 状态、API 封装与 i18n key。**行为变化**:404/405/409-417/422/423/424/426/428/431/451 与 3xx 不再走「不 failover 直接透传上游原始状态码与错误体」路径,这类错误会消耗全部 Key/渠道后以通用 503 收尾;全部渠道失败时不再透传最后一个上游错误详情(`model_not_found` 等可归一化的模型路由错误仍保留精确状态码透传)\n- **订阅中心精简** - 移除分区标题、能力标签与手动添加入口,补齐快捷接入文案;赞助商置顶、new-api 置底,GitHub Copilot 卡移到倒数第二位\n- **移除渠道菜单的试用意图入口**\n- **清理火山套餐历史分支** - 移除 `volcenginePlanModelsHost` 常量与 `endpointFor` 的 `ark_stg` 分支\n\n### 优化\n\n- **展示格式统一** - 统一所有订阅套餐余量展示格式并补全火山\"剩余\"前缀;组合展示统一为单空格分隔;渠道日志不再展示调度摘要原始串\n- **后端代码质量** - 清理 Go lint 问题,gofmt 对齐结构体字段与注释缩进\n\n### 测试\n\n- **Autopilot 分层测试补齐** - L2 SmartRouter + TraceStore 集成、L3 SQLite 生命周期、L4 HTTP 契约、L5 真实上游 smoke 与 SSE golden 回归\n- **回归与稳定性** - 快速探活多模型候选回退测试;healthcheck `per_key_test` flaky 记录断言修复\n- **基准断言同步** - 同步模型能力基准断言,且不再硬编码 registry 刷新日期与分值\n\n### 文档\n\n- **Autopilot 计划与设计** - 新增 trace 灰度实施计划、火山 AFP 感知路由计划、火山套餐 Key 保活恢复计划、渠道异步发现方案,并补充 model+effort 联合决策现状与进度\n- **配置与基准说明** - 火山 provider 模板补充 AFP 路由说明;`make help` 补充 `ARTIFICIAL_ANALYSIS_API_KEY` 用法;量化 Kimi HighSpeed 配额消耗为 3 倍;修正 `canonicalModelToPattern` 的 kimi 分支注释\n\n### 其他\n\n- **停止跟踪 upstream-check 运行时状态文件**\n- **重新生成 model registry 产物**\n\n## [v3.0.0] - 2026-07-23\n\n### 新增\n\n- **Claude Code System Header 过滤** - OpenAI/Gemini/Responses provider 自动过滤 CC billing header、identity 与 subagent 角色描述,减少上游噪音;StripBillingHeader 扩展为完整移除 billing block\n- **分层 system header 过滤接入请求流程** - Messages 入口按 channel-keyHash-model 记忆最优过滤层级,连接错误与 HTTP failover 失败时渐进升级,成功后巩固层级;仅影响 Claude 透传入口,其余 provider 保持原有过滤\n- **托管渠道模型清单重新发现** - 编辑渠道对话框为托管渠道提供重新发现按钮,触发 auto-discover 后轮询任务状态并自动刷新模型清单,支持 zh-CN/en/id 三语\n- **Kimi Code 套餐用量查询** - Kimi 自动托管凭证支持绑定本地 Web 会话令牌,查询并刷新周额度、短期频限、订阅与赠送余额;绑定、刷新与账号列表接口均不回显令牌\n- **DeepSeek 官方账号自动托管** - 快速添加同时创建 Messages、Chat 与 Responses 协议渠道,模型发现统一使用官方 `/models`,编辑渠道时可按凭证查询余额\n- **DeepSeek 峰谷成本倍率配置** - Autopilot 成本评分支持按供应商、时区和时间窗口应用倍率;内置规则延后至 2026-07-20 激活,可通过配置调整生效时间\n- **Autopilot 规范模型能力基准** - 模型注册表新增独立 benchmark 领域向量,按渠道质量证据向下折算并写入路由 trace;首批覆盖 Claude Opus 4.8、GPT-5.6 Terra 与 GPT-5.6 Sol\n- **Autopilot 手动 L3 供应商质量探测** - 新增固定 canary、每日预算与 endpoint×model 画像回写,支持 Messages、Chat、Responses、Gemini 上游协议,响应和持久化均不包含明文 Key 或模型原始输出\n- **new-api 多账号管理** - 支持为已有 new-api 订阅添加多个账号(accessToken),后端提供账号 CRUD API,渠道编辑页面可管理账号列表\n- **Autopilot 智能路由引擎(Phase 1-4 全部完成)** - SmartRouter 评分引擎(§5.3 九项公式 + §5.5 派系偏好 + §5.6 价格偏向)、确定性 TaskClassifier + 路由配置/kill switch、shadow 注入与 assist/auto 模式语义转正、EndpointAttemptPolicy(§4.6.2)+ TryUpstreamWithAllKeys、CapabilityFloor + MinQualityTier reasons、enforce model routing policy boundaries、接入模型注册表能力下界与上下文窗口硬约束、Phase 1 收尾接线、Phase 2 剩余5项集成、P1.5 迁移与回滚契约\n- **Autopilot 画像与健康诊断系统** - ProfileStore(内存缓存 + SQLite 异步持久化)、Profiler L1 被动画像推导、HealthAnalyzer L1 被动健康诊断、后台聚合 worker、健康中心只读 API 与前端视图、LocalModelRuntimeProfile 本地运行时画像与探测、LocalCandidateProvider 进入 endpoint 候选池\n- **Autopilot TrustedRoutingAdvisor 与决策追踪** - 五态状态机 + AdvisorHintEffect 路由约束解析、shadow 评估 + 决策记录 + API、画像变更事件推送与前端时间线(Phase 3A)、RoutingDecisionTrace + TraceStore + trace handlers、智能路由诊断面板与自动模型映射诊断预览\n- **Autopilot 时序与限流感知** - TimeBucketMetrics + QualityTrend 检测(§3.10)、RateLimitDiscoverer shadow(§4.5)、GroupChangeDetector 分组变更检测(§3.9)、FastDecay shadow 评分 + 批量查询 + RPM 运行态注入、接线限流信号/时序/分组变更到 Manager 与健康中心 API\n- **Autopilot L2 探测与模型映射** - L2 轻量探测 worker + 每日预算 + 探测队列、模型自动映射闭环(Phase 3B-2)、StabilityTier 晋降级滞后(Phase 3B-3)、L2 探测自愈闭环(Phase 3B-1)、自动发现完成后一致结果自动写入 SupportedModels\n- **Autopilot 订阅与用量画像** - SubscriptionProfile + 驾驶舱聚合 API + 三 store 接线、§3.2.3 订阅级能力共享 + §3.2.4 用量窗口 Phase 1 shadow、订阅中心 provider adapter(余额自动刷新)\n- **Autopilot 人工路由意图** - ManualRoutingIntent(Phase 1 shadow)、参与 SmartRouter 路由(§4.6.4 人工意图执行语义)\n- **Autopilot 真实请求画像与自适应超时** - 基于真实请求画像、TTFB 自适应响应头超时、采集端点响应头延迟画像、跨重启保留 TTFB 画像\n- **Autopilot auto 上线闸门与自动降级** - auto 上线闸门、自动降级、终态渠道降至 assist 队尾\n- **Autopilot A/B 测试** - 低比例统计抽样双发、ABTestConfig.Validate() 补齐负值/越界兜底\n- **Autopilot SLO regression 自动回滚**\n- **Autopilot 本地任务模板系统**\n- **Autopilot 用户自定义标签系统** (Phase 4 Item 1)\n- **Autopilot 成本报表** - 按用户/模型/渠道/key (Phase 4 Item 2)\n- **Autopilot 渠道推荐** (Phase 4 Item 4)\n- **Autopilot 批量渠道管理** - 导入/导出/模板 (Phase 4 Item 5)\n- **Autopilot new-api 订阅集成** - 后端 adapter + 前端\n- **Autopilot 火山方舟套餐用量查询与展示** - 未绑定 Access Key 时回退内置兜底模型清单,兜底模型清单区分 Agent Plan 与 Coding Plan\n- **Autopilot MiniMax Token Plan 用量查询** - MiMo 绑定控制台 Cookie 查询 Token Plan\n- **渠道自动探测与聚合** - 自动探测并聚合多协议渠道、在编辑页展示协议可用模型、自动识别发现协议类型与展示自动探测协议地址\n- **智谱官方渠道自动识别** - 自动识别智谱官方渠道模板,补齐桌面端渠道自动识别模板\n- **快速添加渠道 Provider 模板化** - 选 provider + 输 key 即可创建、per-key baseURL 探测与绑定\n- **多协议渠道统一展示** - 统一展示多协议渠道、按上游协议展示渠道标签、稳定多协议渠道排序\n- **渠道保活验证模块** - 新增渠道保活验证、渠道健康检查配置与持久化\n- **(Key,模型) 组合级限制与定时自动恢复** - failover 新增组合级限制,定时自动恢复\n- **内置模型清单增强** - /v1/models 补充 max_output_tokens (#266)、同步 LiteLLM 能力与 DeepSWE 证据\n- **模型能力基准数据自动更新** - benchmark 自动更新模型能力基准数据\n- **优云智算套餐余量查询** - compshare 绑定优云智算套餐余量查询、同步套餐并发限速\n- **Kimi Code 模型与按 Key 发现**\n- **火山引擎 Agent 与 Coding Plan 自动识别**\n- **自动托管账号持久化层级** - 账号级凭证接口、切换凭证差异更新、聚合旧版托管协议渠道\n- **new-api 安全分组倍率路由**\n- **远程预置更新运行时** - 预置数据集中管理 + 运行时可更新架构(Phase 1)、完成远程预置更新运行时\n- **订阅来源等级只读展示** - 来源等级由来源类型系统推导,改为只读展示\n- **火山引擎与 BytePlus 赞助**\n- **套餐类渠道 Key 行余量摘要补齐多时间窗**\n- **桌面端 autopilot 对齐** - 管理面板+健康中心+骨架(第一波)、cockpit概览+new-api订阅+试用意图(第二波)\n- **桌面端渠道自动探测与复用同步** - 同步渠道自动探测与复用、托管渠道识别模板与徽章、Codex 生图工具权限探测、TTFB 首字节 P95 列、auto 上线闸门与自动降级到桌面端健康中心\n- **Responses 音频 content block 丢弃告警** - converters: Responses 音频 content block 丢弃时输出告警日志\n- **前端 Health Center / Subscriptions / Cockpit 视图**\n- **渠道卡片健康 badge + 来源标签系统(§8.2)**\n\n### 修复\n\n- **本地部署渠道重新发现后显示\"来源未知/历史记录未保存\"** - `writeProfiles` 与 `buildEndpointInventory` 统一用 `CanonicalBaseURL` 规范化 baseURL,修复带 `/v1` 后缀时 endpointUID 不一致导致画像写入后无法被 `ListActiveByChannel` 命中的问题\n- **套餐型 Provider Key 限额重置后不自动恢复** - Kimi/MiMo/优云智算/火山用量刷新后新增 `TryRestoreDisabledKeysByUsage`,按各 Provider 用量快照的重置时间与剩余额度自动恢复因余额/限额不足被拉黑的 Key;同时修复 `syncManagedAccountsFromChannels` 仅从活跃 Key 重建凭证池、导致被拉黑托管 Key 的 Console token/AccessKey/用量快照在下次配置保存时丢失的问题\n- **Provider 多协议账号部分写入** - 首次创建与旧账号补协议改为验证后单次提交,失败不残留半成品渠道,并保留 MiMo 区域及火山套餐端点亲和性\n- **快速添加成功后对话框未关闭** - provider 自动托管创建成功后立即结束交互,模型发现继续在后台执行\n- **Autopilot 配置迁移与运行态隔离** - 旧配置块解析或升级失败时回退安全默认值,支持结构体指针叠加,并避免环境急停经管理 API 写回配置文件\n- **辅助路由协议回归** - `assist` 保留无法评分的原候选供 failover,真实路由 trace 回填实际渠道,修复模型不支持错误重试、普通 Key 的模型限制执行、relay 耗尽误限制及 Messages 响应模型字段丢失\n- **new-api 验证失败** - 修复某些 new-api fork(如 Veloera/voapi)要求 `New-API-User` header 导致的 401 错误,新增 VerifyWithFallback 两阶段验证回退逻辑\n- **套餐 Key 恢复边界条件收紧** - P1 收紧恢复条件为 AND 语义、P2 修复 Kimi/火山套餐恢复边界条件\n- **system header 过滤稳定性修复** - 修复 panic、过期误判与误升级问题、字符串形式 system 未过滤 CC header 的问题\n- **Autopilot 替代模型选择优化** - 按任务难度选择替代模型、优先选择更强同档模型、使用渠道模型倍率排序、按能力下限优化排序、避免常规任务过度升级模型、修正多模态模型能力路由\n- **Autopilot 模型发现与画像修复** - 记录模型发现来源与时间、自适应模型解析修正、endpoint metrics identity 对齐、profile model support decisions 遵守、当前有效画像清单对账\n- **Autopilot 路由约束对齐** - scheduler diagnose 与 request profile 对齐、shadow 与自动路由约束对齐、dry-run 与自动路由决策对齐、诊断画像与路由追踪统一\n- **Autopilot 配置迁移与隔离修复** - 自动升级旧版 Autopilot 配置、配置迁移隔离完善、环境急停经管理 API 写回配置文件隔离\n- **火山方舟 Agent Plan 修复** - 探针与协议入口区分、Key 端点验证修复、套餐 Key 验证请求特征对齐、Coding Plan 用量刷新修复、套餐模型发现签名修复\n- **渠道展示与编辑修复** - 托管渠道真实模型范围展示、额度禁用状态与用量展示修正、渠道行展示与官方渠道命名/官网编辑优化、渠道添加与官方渠道编辑流程简化\n- **自动托管渠道编辑精简** - 多轮精简自动托管渠道编辑项、收敛编辑项\n- **Autopilot 端到端流程修复** - auto-add/auto-status 端到端流程、辅助路由协议回归、终态渠道降至 assist 队尾\n- **MiMo 自动发现修复** - 自动发现状态修复、自动发现失败修复、自动添加多协议渠道\n- **桌面端同步修复** - 额度禁用状态与 Key 计数、baseUrl 清理路径、自动渠道命名简化\n- **前端图标注册修复** - mdi-check-all、DeepSeek 余额图标、mdi-connection、ApiKeyManagementSection 缺失 mdi 图标、官方渠道标识图标\n- **前端 TypeScript 编译错误修复**\n- **Compshare system roles 规范化** - messages: normalize Compshare system roles\n- **LLM 统一渠道视图置顶/置底排序修复**\n- **模型定价同步与未配置成本标记**\n- **重新发现轮询识别 failed 状态并展示错误**\n- **拉取渠道模型列表时使用 key 级绑定 baseUrl**\n- **模型能力基准多来源更新与图表生成修复**\n- **熔断引入模型多样性门槛与渠道组合失败熔断**\n- **自动托管渠道官网地址持久化**\n- **快速添加渠道名称预览修复**\n- **最终上游模型日志记录** - logging: record final upstream model\n- **内部元数据泄漏到续轮请求阻止** - responses: 阻止内部元数据泄漏到续轮请求\n- **快速添加前去重 API Key**\n- **渠道自动命名简化与仪表盘轮询合并**\n- **容器后端验证的前端嵌入修复**\n- **baseUrl 清理剔除 /api-keys 创建页路径**\n- **disabled managed credentials 跳过** - routing: skip disabled managed credentials\n- **Claude Code system 指纹与客户端探针特征对齐**\n- **小屏协议地址布局优化与协议名称换行避免**\n- **添加弹窗正文滚动与 usage 控制台路径识别**\n- **Autopilot 按真实协议创建自定义渠道**\n- **Autopilot DeepSeek 官方账号托管完善**\n- **逻辑渠道多协议日志汇总**\n- **按客户端协议持久化画像分类与发现完成后立即持久化**\n- **绑定模型发现结果保留**\n- **官方 provider 凭证池复用与自动托管官方渠道身份标明**\n- **按 endpoint binding 隔离模型调度** - routing: 按 endpoint binding 隔离模型调度\n- **旧版托管协议渠道聚合**\n- **官方渠道改用智能调度**\n- **快速添加成功后关闭弹窗**\n- **content_part 流式事件识别** - responses: 识别 content_part 流式事件\n- **schema 迁移幂等自愈 advisor_decisions.reason 列**\n- **scoreEndpointForKey EndpointUID 与 handlers 层不一致修复**\n- **RateLimitApplier kill switch 测试闭包捕获值拷贝问题**\n- **Responses input item additional_tools 剥离**\n- **未知健康状态徽标颜色调整**\n- **快速添加上游类型选择移除**\n- **黑名单密钥恢复状态提示**\n\n### 重构\n\n- **订阅中心重构为 Provider 卡片式快捷接入面板** - 移除订阅列表表格展示,改为双栏布局:左侧 Provider 卡片(GitHub Copilot/new-api/手动添加),右侧详情表单;订阅中心只负责接入新订阅\n- **Agent skills 迁移至 .claude/skills** - 通过 symlink 将 agent skills 迁移到 .claude/skills 目录\n- **协议模型可用性 diff 视图重新设计** - frontend: 重新设计协议模型可用性差异视图\n- **Kimi 用量展示简化** - frontend: 简化 Kimi 用量展示以匹配官方 CLI\n\n### 优化\n\n- **路由性能优化** - config: clone only selected upstream;routing: cache request channel snapshots、prefilter incapable managed channels、prefilter channels without usable keys\n\n### 文档\n\n- **Autopilot 设计文档** - 新增同订阅多 Key 能力共享/配额负载均衡(§3.2.3)与用量窗口(§3.2.4)设计、任务域优势矩阵(§5.7)与思考等级差异(§5.8)设计、内置模型清单设计(§4.2.1)、new-api 集成与订阅 Provider Adapter 框架、Phase 3A/3B/4 全部完成状态更新\n- **桌面端文档** - 补充后端发现与端口附着约定\n- **Codex 上游协议变更评估** - 追加 Codex rust-v0.145.0 上游协议变更评估、新增 gpt-5.6 适配与 codex imagegen 上游文生图待办\n- **SignPath 代码签名致谢** - 文档站首页与 README 添加 SignPath 代码签名致谢,logo 放大并链接到官网\n- **Star History 图表更新** - 调整为 Markdown 图片并更新 token\n- **火山引擎推广链接** - README 为火山引擎注册即领 2500 万 Tokens 添加推广链接\n\n### 其他\n\n- **Windows 签名切换至正式证书策略** - ci: 切换 Windows 签名至正式证书策略\n- **Serena 项目配置更新**\n- **Autopilot 日志降噪** - 降低画像落盘成功日志噪声、移除画像推导冗余日志\n- **Codex 0.143.0 upstream check 记录**\n- **Apple Container 隔离验证** - 添加 Apple Container 隔离验证环境\n- **下载按钮改为两行网格布局** - style: 下载按钮改为两行网格布局\n\n## [v2.9.37] - 2026-07-07\n\n### 新增\n\n- **渠道选择 Trace 全链路诊断** - scheduler: 新增 channel selection trace,覆盖候选过滤、活跃模型过滤、选择失败原因,支持 `/v1/models/diagnose` 干跑视图与流式日志摘要\n- **响应续思折叠** - responses: 上游截断的 reasoning continuation 在代理层自动折叠,避免客户端收到不完整思考链\n- **渠道配置发现增强** - discovery: 增强渠道配置发现能力,改进推荐算法\n- **瞬态渠道发现** - discovery: 新增瞬态渠道发现机制,支持快速探测上游可用性\n- **断路器温和策略改为新默认** - circuit-breaker: 调整断路器策略预设,温和策略取代激进策略作为新默认值\n- **Agnes 模型描述注册** - registry: 为 Agnes 系列模型补充描述信息\n- **store-update 完成后自动打开下载目录** - skill: store-update 下载完成后自动在文件管理器中打开目标目录\n\n### 修复\n\n- **discovery 工具调用探测协议选择错误** - discovery: messages 渠道 serviceType 为 responses/chat/gemini 时,工具调用能力探测始终用 Claude 格式导致假阴性;修复为按 serviceType 自动选择 responses/chat/gemini 对应格式\n- **discovery 不再生成 supportedModels 通配白名单** - discovery: 发现流程自动写入 \\*alias\\* 通配符模式(\\*opus\\*、\\*gpt\\* 等)到 supportedModels,实际上渠道无需此限制;删除生成逻辑,apply 时始终清空旧值\n- **discovery 协议探测失败时模型映射为空** - discovery: 用户显式选定 channelKind 但该协议探测失败时,buildDiscoveryMappingRecommendation 返回空映射;降级到任意成功协议的模型重新构建别名映射\n- **thinking probe 误覆写 passbackReasoningContent** - compat: 历史 thinking block 探测返回 400/422 时错误地将 passbackReasoningContent 置为 false;此标志由独立 thinking 预算探测决定,不受历史块探测影响\n- **协议延迟统计含超时模型** - discovery: runDiscoveryProtocolProbe 对所有模型(含超时)的延迟做均值,导致有效延迟数据被拉高;改为仅统计成功模型均值\n- **gemini 渠道 reasoningMapping 暂不生成** - discovery: Gemini handler 目前未消费 reasoningMapping,写入配置只产生无效噪声;明确不为 gemini 渠道生成该字段,待 Gemini thinking 路径支持后再启用\n- **compat evidence 迭代顺序不稳定** - discovery: map 迭代顺序不确定导致证据列表每次不同;改为先排序 key 再遍历\n- **熔断器未将 no_available_account 计为过载** - failover: 上游返回 no_available_account 未触发熔断,新增 FailureClassOverloaded 分类\n- **serviceType 派生协议回退优先** - discovery: 当用户未显式指定 channelKind 时,优先使用 serviceType 派生的协议作为回退,而非仅依赖探测成功模型\n- **回退成功模型未传播到后续探测** - discovery: 首次协议探测成功得到的模型在后续探测中被忽略,导致其他协议探测缺少候选;改为将回退成功模型注入所有后续探测\n- **目标协议失败时未从回退协议构建映射** - discovery: 目标协议探测完全失败时 buildDiscoveryMappingRecommendation 返回空;改为从任意成功协议的模型重新构建别名映射\n- **发现流程白名单生成污染 supportedModels** - discovery: 自动写入通配符模式到 supportedModels 实际上限制了渠道可见模型;删除生成逻辑,apply 时始终清空旧值\n- **messages 渠道工具调用探测格式错误** - discovery: 工具调用能力探测始终用 Claude 格式;修复为按 serviceType 自动选择 responses/chat/gemini 对应格式\n- **显式渠道协议推荐被覆盖** - discovery: 用户明确指定 channelKind 时协议推荐仍被探测结果覆盖;改为优先保留用户选择\n- **malformed 响应未触发重试** - failover: 上游返回 malformed 响应体时未触发重试机制;新增重试逻辑\n- **Vue SFC 模块类型声明缺失** - desktop: 前端 typecheck 无法识别 .vue 文件模块声明;补充 env.d.ts 中的 SFC 模块声明\n- **桌面端开发模式未加载嵌入前端资源** - desktop: Wails dev 模式未正确加载 embed.FS 中的前端资源\n- **Wails 开发服务器日志过于冗杂** - desktop: 抑制 Wails dev server 信息级别日志输出\n- **.vue 导入 TS2307 类型错误** - frontend: tsconfig 未包含 env.d.ts 导致 .vue 文件导入报 TS2307\n- **模型列表未暴露上下文窗口** - models: /v1/models 返回结果未包含 context window 信息\n- **模型聚合等待窗口过短** - discovery: 多上游模型聚合的等待窗口不够长,部分慢响应渠道被遗漏\n- **discovery 重复创建 channel models fetcher** - discovery: 每次发现流程重新创建 fetcher 实例,改为复用已有实例\n- **上游临时过载未触发熔断** - backend: 上游返回 503/429 等临时过载状态码时未正确触发断路器\n- **未配置模型时 /v1/models 返回空** - models: 当模型未在上游注册时,回退到渠道配置的模型列表\n- **discovery 通道推荐 UI 对齐** - discovery: 通道推荐 UI 展示对齐设计稿\n- **上下文能力菜单被遮挡** - frontend: 上下文能力下拉菜单被其他元素遮挡\n- **渠道 URL 包含 profile wallet 路径** - frontend: 前端展示渠道 URL 时未剥离 profile wallet 路径前缀\n- **渠道 URL 包含 admin 路径** - frontend: 前端展示渠道 URL 时未剥离 /admin 路径前缀\n- **桌面端上下文模型下拉层级错误** - desktop: 上下文模型下拉菜单被高级选项遮挡,提升层级\n\n### 优化\n\n- **对齐 model registry map keys 格式** - style: 统一模型注册表 map keys 对齐方式,移除测试文件多余空行\n\n### 文档\n\n- **Channel Autopilot 设计文档** - docs: 新增 Channel Autopilot 自动路由设计文档\n- **Channel Autopilot 端点级粒度设计** - docs: 细化 Channel Autopilot 到端点级别的路由粒度设计\n- **Channel Autopilot 时间质量感知** - docs: 为 Channel Autopilot 设计补充时间维度质量感知机制\n- **Channel Autopilot 手动路由意图** - docs: 新增手动路由意图设计,支持用户显式指定路由偏好\n- **Channel Autopilot 成本感知路由** - docs: 新增成本感知路由设计,在质量与成本间自动权衡\n- **Channel Autopilot 原生 image/vector 路由** - docs: 将原生 image 与 vector 渠道纳入自动路由范围\n- **可信本地路由顾问** - docs: 新增可信本地路由顾问设计,基于历史数据提供路由建议\n- **用户价格偏向 (Cost Preference) 设计** - docs: 新增用户价格偏向设计,支持按成本偏好调整路由权重\n- **上游供应商质量差异设计 (ProviderQualityScore)** - docs: 补充上游供应商质量差异评分设计\n- **模型派系偏好 (Model Family Preference) 设计** - docs: 新增模型派系偏好设计,支持用户指定偏好的模型家族\n- **ModelFamily 枚举扩展至 17 个派系** - docs: 模型派系枚举扩展至 17 个,来自模型注册表\n- **Channel Autopilot 验收契约** - docs: 新增 Channel Autopilot 验收测试契约文档\n\n### 改进\n\n- **保留用户显式选定的渠道协议推荐** - discovery: 用户明确指定 channelKind 时优先保留别名,不受协议探测结果覆盖\n\n## [v2.9.36] - 2026-07-05\n\n### 新增\n\n- **集成 vectors Embedding 兼容性面板到桌面端渠道编辑流程** - desktop: 集成 vectors Embedding 兼容性面板到渠道编辑流程\n- **新增 vectors Embedding 兼容性配置面板组件** - desktop: 新增 vectors Embedding 兼容性配置面板组件\n- **移植 embedding 兼容性类型/序列化/i18n 到桌面端** - desktop: 移植 embedding 兼容性类型、序列化与 i18n 到桌面端\n- **OpenAI 响应思考标签提取为原生 reasoning 字段** - provider: OpenAI 响应思考标签提取为原生 reasoning 字段\n\n### 修复\n\n- **对齐桌面端 capability 测试反馈** - desktop: 对齐桌面端 capability 测试反馈\n- **使用结构化对话轮次** - cockpit: 使用结构化对话轮次替代纯文本\n- **embedding 兼容性配置边界问题** - vectors: 修复 embedding 兼容性配置边界问题\n- **剥离 embedding 请求体中的 stream 字段** - vectors: 剥离 embedding 请求体中的 stream 字段避免上游 SSE 误触发\n\n### 优化\n\n- **提取 buildEndpointURL 正则为 package-level var** - vectors: 提取 buildEndpointURL 正则为 package-level var 避免重复编译\n\n### 其他\n\n- **补齐 embedding capability 与 handler 边界测试** - test: 补齐 embedding capability 与 handler 边界测试\n\n## [v2.9.35] - 2026-07-04\n\n### 新增\n\n- **展开态当前渠道点击支持固定与取消固定切换** - cockpit: 展开态当前渠道点击支持固定与取消固定切换\n- **快捷渠道覆盖支持固定与取消固定** - cockpit: 快捷渠道覆盖支持固定与取消固定\n- **发布成功后自动调用 store-update** - github-release: 发布成功后自动调用 store-update\n\n### 修复\n\n- **向量化渠道兼容性配置边界问题** - vectors: 修复桌面端 Embedding 兼容性面板未指定 normalized 时崩溃、Vectors 渠道重名更新、非法 embeddingCapabilities 保存与非法 input 本地校验缺口\n- **拦截 Claude Code 模型切换探针避免空响应误判** - messages: 拦截 Claude Code 模型切换探针避免空响应误判\n- **修复 copilot 渠道 /v1/models 代理返回 404** - models: 修复 copilot 渠道 /v1/models 代理返回 404\n- **修正 gitignore 规则并添加 git 代理自动检测** - store-update: 修正 gitignore 规则并添加 git 代理自动检测\n\n### 其他\n\n- **同步 skill 更新到 .agents/skills/** - chore: 同步 skill 更新到 .agents/skills/\n\n## [v2.9.34] - 2026-07-04\n\n### 新增\n\n- **注册 mdiCash 图标** - ui: 注册 mdiCash 图标,补充前端图标资源\n- **5xx + Retry-After 也触发限速 cooldown** - ratelimit: 上游返回 5xx 或 Retry-After 头时同样触发限速 cooldown\n- **上游临时过载检测与渠道冷却机制** - failover: 新增上游临时过载检测与渠道冷却机制,提升故障转移鲁棒性\n\n### 修复\n\n- **单模型渠道 CostUSD 不显示** - metrics: 修复单模型渠道 CostUSD 不显示的问题\n- **限速窗口单位语义修正** - ratelimit: 修正限速窗口单位语义,字段值直接以秒为单位\n\n### 其他\n\n- **Serena 本地记忆目录加入忽略列表** - chore: 将 Serena 本地记忆目录加入 gitignore\n\n## [v2.9.33] - 2026-07-04\n\n### 修复\n\n- **DeepSeek context_management 不兼容导致 400** - providers: 过滤 DeepSeek Claude 兼容端点不支持的 `context_management.clear_thinking_20251015` 编辑和 `context-management-2025-06-27` beta header,修复 Claude Code 首轮/recap 请求的 thinking 回传 400\n\n## [v2.9.32] - 2026-07-03\n\n### 修复\n\n- **ChannelManager canDeleteChannel 引用错误** - desktop: 修复 ChannelManager canDeleteChannel 引用错误\n\n## [v2.9.31] - 2026-07-03\n\n### 新增\n\n- **DeepSeek tool_use passback 双重降级缓存** - thinking: DeepSeek tool_use passback 双重降级缓存\n\n### 修复\n\n- **opaque encrypted_reasoning 占位保障 DeepSeek thinking passback** - responses: opaque encrypted_reasoning 占位保障 DeepSeek thinking passback\n\n## [v2.9.30] - 2026-07-03\n\n### 新增\n\n- **原生 WebSocket v2 透传** - responses: 支持原生 WebSocket v2 透传\n- **流式 session 回写与 incomplete 合成** - responses: 流式路径 session 回写并在 post-commit stall 时合成 incomplete\n- **Responses reasoning encrypted_content 缓存** - thinkingcache: 扩展存储 Responses reasoning encrypted_content\n- **分层保真 compact** - compact: 增加分层保真 compact 与 function_call_output 摘要\n\n### 修复\n\n- **快速创建渠道数据源提交** - desktop: 统一快速创建渠道时提交数据源\n- **视觉回退上下文校验** - failover: 校验 vision fallback context,避免无效视觉回退上下文继续传播\n- **agent skills 路径与 Store MSIX tag 下载** - skill: 修复 `.agents/skills` 内 skill 文档误指向 `.claude/skills` 的路径,并确保 store-update 脚本的 `--tag` 会传递给 `gh release download`\n\n### 其他\n\n- **desktop-store-update 技能清理** - skill: 删除 desktop-store-update 技能并更新工具配置\n- **agent skills 同步** - skill: 同步 agent skills 文档、脚本和 upstream-check 状态\n\n## [v2.9.29] - 2026-07-03\n\n### 新增\n\n- **token 成本(USD) 统计** - metrics: 新增 token 成本(USD) 统计\n- **loadShed reaper 与 cooldown 软跳过改进** - scheduler: loadShed reaper 自动推进到期状态、cooldown 软跳过与 scoped limiter 清理改进\n- **preflight 空响应详情诊断与敏感信息脱敏** - stream: preflight 空响应详情诊断与敏感信息脱敏\n\n### 修复\n\n- **MiniMax M2 系列上下文与输出元数据** - model-registry: 修正 MiniMax M2 系列上下文与输出元数据\n- **火山方舟 quota exceeded 识别与临时限流误判** - failover: 识别火山方舟 quota exceeded 并修复临时 token-per-minute 限流误判\n- **桌面渠道模型映射下拉框显示** - desktop: 修复渠道模型映射下拉框显示并清理调试日志\n\n### 文档\n\n- **Microsoft Store submission API 凭据获取说明** - skill: 补充 Microsoft Store submission API 凭据获取说明\n\n### 其他\n\n- **desktop-store-update 技能** - skill: 新增 desktop-store-update 技能\n\n## [v2.9.28] - 2026-07-02\n\n### 新增\n\n- **BIND_HOST 环境变量支持** - server: 新增 BIND_HOST 环境变量以绑定指定网络接口\n- **Claude thinking 回填缓存** - thinkingcache: 新增 Claude thinking 回填缓存模块\n- **子代理精确识别** - agent: 通过 X-Claude-Code-Agent-Id 精确识别子代理\n- **统一配置管理与 CLI 选项** - thinkingcache: 统一 thinking 缓存配置管理与 CLI 选项\n- **DeepSeek Claude thinking 缓存诊断** - compat-diagnose: DeepSeek Claude 渠道启用 thinking 缓存诊断\n- **DeepSeek Claude 预设 thinking 缓存模式** - preset: DeepSeek Claude 预设切换到 thinking 缓存模式\n\n### 修复\n\n- **stop_reason 仅事件误判** - stream: stop_reason 仅事件不再误判为语义内容\n\n### 重构\n\n- **override 日志增强** - scheduler: 增强 override 日志输出\n\n## [v2.9.27] - 2026-07-02\n\n### 新增\n\n- **渠道预设 maxTokens 支持** - desktop/preset: 渠道预设新增 maxTokens 字段并支持桌面端配置\n- **渠道 preset authHeader 字段** - preset: 渠道 preset 新增 authHeader 字段\n- **thinking-only 响应识别** - stream: preflight 支持 thinking-only 响应识别为有效内容\n- **订阅 Tab 优化与 preset 清理** - desktop: 订阅 Tab 优化与 preset channel_types 清理\n- **LongCat-2.0 与 Step 3.7 Flash 模型注册** - models: 新增 LongCat-2.0 与 Step 3.7 Flash 内置模型注册\n\n### 修复\n\n- **轮询立即触发并统一活跃 job 收集** - capability: 修复轮询调度延迟与活跃 job 收集逻辑\n- **兼容上游裸 JSON 行 SSE 流** - claude: 兼容上游裸 JSON 行 SSE 流\n\n### 重构\n\n- **ENV 字段移至服务器配置组** - desktop: 将 ENV 字段移至服务器配置组 PORT 之后\n- **SSE 流式规范化重构** - claude: 重构 SSE 流式规范化并补齐响应必需字段\n- **openai-code-v2 channel_types 移至测试层** - capability: 将 openai-code-v2 channel_types 移至测试层\n\n### 其他\n\n- **Windows MSIX 打包图标资源** - build: 添加 MSIX 打包所需多尺寸图标资源\n\n## [v2.9.26] - 2026-07-01\n\n### 新增\n\n- **Copilot 代理支持** - copilot: OAuth 流程支持代理地址并优化超时处理,支持通过代理进行 Copilot token 交换,新增 Copilot 渠道默认 BaseURL 自动回填\n- **Copilot 订阅中心** - desktop: 新增订阅中心页面,将 Copilot OAuth 从渠道创建移至独立 Tab,支持多账号 key 池管理、多渠道目标与诊断结果展示\n- **Copilot 预设与前端适配** - desktop/preset: 新增 GitHub Copilot 预设并支持 proxyUrl 字段;web/frontend: Copilot 渠道支持代理地址输入,channelPayload 支持 Copilot 默认 BaseURL\n- **订阅 Tab 账号展示增强** - desktop: 增强订阅 Tab 账号展示与 Copilot 模型获取,支持国际化\n- **Claude Sonnet 5 注册** - models: 注册 Claude Sonnet 5 模型\n- **模型元数据增强** - models: 丰富模型元数据,支持 Anthropic 家族分类,优化模型发现机制并添加缓存降级与 keypool 集成\n- **Claude Desktop 连接探测** - messages: 内置 Claude Desktop 连接探测响应\n- **本地 HTTPS 自签名证书** - tls: 支持本地 HTTPS 自签名证书\n- **Codex 原生 WebSocket 协议** - responses: 支持 Codex 原生 WebSocket 协议\n- **商汤日日新接入** - sensenova: 新增商汤日日新 LLM 平台接入\n- **讯飞 Coding Plan 迁移** - preset: 讯飞 Coding Plan 迁移并支持 serviceType 配置\n- **MiMo 计划选择器** - ui: 为 Codex 和 OpenCode 添加 MiMo 计划选择器\n- **超时控件优化** - ui: 将超时控件暴露为秒级显示\n- **macOS 应用内更新** - desktop: 新增 macOS 应用内更新打包支持\n- **会话预览** - ui: 添加可展开的会话预览功能\n- **渠道 Key 复用显示** - desktop: 渠道 Key 复用时显示当前值\n- **image_generation 探测** - diagnose: 探测上游 image_generation tool 支持\n- **渠道编辑交互优化** - channel-editor: 优化渠道编辑交互体验\n\n### 修复\n\n- **copilot serviceType 映射** - capability: 修复 copilot serviceType 映射到 responses 协议\n- **Copilot 订阅 token 复用** - desktop: 修复 Copilot 订阅保存 token 复用与渠道状态管理\n- **copilot BaseURL 清空** - desktop: 修复 copilot 渠道 BaseURL 因空版本前缀被错误清空的问题\n- **normalizeMetadataUserId 协议限制** - config: 将 normalizeMetadataUserId 限定为仅 Messages 协议生效\n- **TLS 证书路径提示** - tls: 证书路径为相对路径时增加绝对路径提示\n- **responses 渠道 normalizeMetadataUserId** - channel-editor: responses 渠道默认关闭 normalizeMetadataUserId\n- **Copilot OAuth 超时上下文** - copilot: 补充 OAuth 请求超时与错误上下文\n- **模型点击排队** - capability-tests: 修复桌面端模型点击排队问题\n- **provider key 资产匹配** - channel: currentAsset 按 planID 精确匹配 provider key 资产\n- **keysByProvider 去重** - preset: keysByProvider 去重保留首个 asset\n- **plan-scoped key 存储** - configservice: plan-scoped key 存储不覆盖 provider channel key\n- **Responses URL 重复拼接** - api: Responses URL 末尾已含 endpoint 时避免重复拼接\n- **MiMo reasoning effort** - api: 规范化 MiMo Responses reasoning effort 上限\n- **Codex tool_search 剥离** - api: 剥离 Codex tool_search 工具调用\n- **compaction 流** - api: 允许 compaction 仅含 usage 的流式响应\n- **MiMo token plans** - desktop: 允许 MiMo token plans 用于 responses 协议\n- **brew deprecated syntax** - brew: 替换已废弃的 `depends_on` 字符串语法\n- **模型能力同步延迟** - ui: 编辑期间延迟模型能力同步\n- **thinking reasoning 参数** - api: 规范化 thinking reasoning 参数\n- **MiMo thinking style** - channel: MiMo 预设使用 thinking style\n- **渠道高级选项布局** - ui: 改善渠道高级选项布局\n\n### 重构\n\n- **ResolveTokenWithProxy** - handlers: 全量替换 Copilot ResolveToken 为 ResolveTokenWithProxy\n- **渠道编辑器 composables 拆分** - channel-editor: 接入 useEditChannelModal 主 composable,提取禁用 Key 恢复/模型获取/Payload 构建、表单选项/派生计算/预设/工具函数、UI 辅助 composables\n- **应用 composables 提取** - app: 提取 useToasts + useAppController composable,提取 savedProviderKeyForPlan 按 planID 匹配资产 key\n- **ChannelEditDialog composables** - desktop: 提取 ChannelEditDialog composables\n- **configservice 瘦身** - configservice: service.go 瘦身为纯调度层,提取 Provider Key 管理、预览/试运行、OpenCode 配置、Codex 配置与会话迁移、路径解析与 Claude 配置、file/toml/jsonc 格式辅助函数\n- **渠道自定义参数面板分离** - ui: 将渠道自定义参数面板拆分为独立组件\n\n### 文档\n\n- 补充桌面端与环境变量文档\n- 清理已完成的上游版本评估条目\n- 添加 Claude Code 2.1.196 上游版本审查\n- 澄清快速模式使用成本说明\n\n### 测试\n\n- 覆盖 Codex tool_search 剥离测试\n\n## [v2.9.25] - 2026-06-29\n\n### 新增\n\n- **Copilot 授权码一键复制** - channel: 为 Copilot 授权码添加一键复制操作,便于完成 OAuth 配置\n\n### 修复\n\n- **提示浮层不被容器裁剪** - ui: 保持 tooltip 显示在被裁剪容器之上\n- **桌面端熔断器弹窗布局** - desktop: 保持熔断器弹窗布局在正常文档流中,避免内容溢出错位\n- **移除全局历史图片限制控件** - ui: 移除全局 historical image limit 控件,避免与渠道级配置混淆\n- **历史图片限制改为渠道级配置** - config: 将 historical image limit 调整为渠道级配置\n\n### 重构\n\n- **抽取 Copilot OAuth 路径常量** - channel: 提取 Copilot OAuth 路径常量并修复授权码清理逻辑\n\n### 文档\n\n- **澄清渠道级图片限制变更** - changelog: 澄清历史图片限制已改为渠道级配置\n\n## [v2.9.24] - 2026-06-29\n\n### 新增\n\n- **MiMo 预设思考强度配置** - channel: 将 MiMo 预设的思考强度配置学习到一键预设\n\n### 修复\n\n- **Volc Ark coding 端点预设** - desktop: 修正 Volc Ark coding 端点预设配置\n- **渠道日志与 Key 错误展示** - desktop: 改善渠道日志和 key 错误信息的显示\n- **认证区域置于重定向前** - channel: 将认证配置区域放在重定向区域之前\n- **Copilot OAuth 外部链接** - desktop: 修复 Copilot OAuth 链接在桌面端无法打开的问题\n\n### 其他\n\n- **延长桌面端 macOS 公证超时** - release: 延长桌面端 macOS 公证任务超时时间\n\n## [v2.9.23] - 2026-06-29\n\n### 修复\n\n- **剥离不支持的 Claude thinking blocks** - responses: strip unsupported Claude thinking blocks\n- **优化 Claude 兼容性诊断** - compat: refine Claude compatibility diagnosis\n- **刷新熔断窗口近期历史** - metrics: refresh breaker windows from recent history\n\n### 测试\n\n- **稳定 thinking 兼容性覆盖** - responses: stabilize thinking compatibility coverage\n\n### 其他\n\n- **延长 macOS 公证超时时间** - release: extend macOS notarization timeout\n\n## [v2.9.21] - 2026-06-28\n\n### 新增\n\n- **渠道日志复制操作** - channel: 为渠道日志添加复制操作,便于快速复制日志内容排查问题\n- **GPT-5.6 能力探测与渠道选项** - capability: 接入 GPT-5.6 能力探测与渠道配置选项\n- **GPT-5.6 Amazon Bedrock 模型定义** - model-registry: 新增 GPT-5.6 Amazon Bedrock 模型定义\n\n### 修复\n\n- **保留 Claude 主 API Key** - desktop: 修复桌面端配置同步时 Claude 主 API key 被覆盖或丢失的问题\n- **Base URL 哈希诊断校验** - 修复 base url hash 诊断校验逻辑,提升问题定位准确性\n- **桌面端流式超时默认值** - desktop: 对齐并保留 stream timeout 默认值\n- **渠道菜单浮层稳定性** - frontend: 稳定渠道操作菜单 overlay 展示,避免交互抖动\n- **Copilot OAuth 国际化文案** - desktop: 补齐 Copilot OAuth 桌面端多语言文案\n- **输出 token 按模型限制裁剪** - routing: 根据模型能力限制下调超限输出 token,避免上游参数错误\n- **能力探测携带必需 thinking 参数** - capability: 强制思考模型探测时自动携带 thinking/reasoning 参数,避免能力误判\n- **Kimi K2.7 输出限制修正** - model-registry: 将 Kimi-K2.7-Code 最大输出与推荐输出修正为 32768\n\n## [v2.9.20] - 2026-06-27\n\n### 新增\n\n- **展示 Claude Code recap** - cockpit: 展示 Claude Code recap\n- **记录并展示 Claude Code recap** - cockpit: 记录并展示 Claude Code recap\n\n### 其他\n\n- **限制 macOS 公证任务时长** - release: 限制 macOS 公证任务时长\n- **并行化 macOS 签名公证流程** - release: 并行化 macOS 签名公证流程\n\n## [v2.9.19] - 2026-06-27\n\n### 新增\n\n- **为对话驾驶舱补充悬浮提示** - ui: 为对话驾驶舱补充悬浮提示\n\n### 修复\n\n- **strip Codex response metadata passthrough** - strip Codex response metadata passthrough\n- **处理 Responses 流式错误事件** - responses: 处理 Responses 流式错误事件\n- **补齐印尼语缺失翻译** - i18n: 补齐印尼语缺失翻译\n- **跳过 Claude 空输出重试标题** - backend: 跳过 Claude 空输出重试标题\n- **remove channel logs refresh button** - desktop: remove channel logs refresh button\n\n### 重构\n\n- **抽取 Responses WebSocket 回退处理器** - backend: 抽取 Responses WebSocket 回退处理器\n\n### 其他\n\n- **配置 macOS 签名公证** - desktop: 配置 macOS 签名公证\n\n## [v2.9.18] - 2026-06-26\n\n### 新增\n\n- **捕获 WebView 前端运行时错误** - desktop: 捕获 WebView 前端运行时错误\n- **支持前端错误写入桌面日志** - desktop: 支持前端错误写入桌面日志\n- **同步渠道预设兼容选项** - channel: 同步渠道预设兼容选项\n- **优化兼容诊断默认建议** - channel: 优化兼容诊断默认建议\n- **展示主对话最新消息** - frontend: 展示主对话最新消息\n- **更新 MiMo V2.5 模型能力** - model-registry: 更新 MiMo V2.5 模型能力\n- **添加兼容性诊断入口** - channel: 添加兼容性诊断入口\n- **添加兼容性诊断** - channel: 添加兼容性诊断\n- **优化驾驶舱展开对话卡片展示** - ui: 优化驾驶舱展开对话卡片展示\n- **添加驾驶舱关联对话导航** - ui: 添加驾驶舱关联对话导航\n- **GitHub Copilot 共享认证支持与安全加固** - GitHub Copilot 共享认证支持与安全加固\n- **支持 Chat/Messages/Gemini 入口转换到 Copilot** - copilot: 支持 Chat/Messages/Gemini 入口转换到 Copilot\n- **优化添加渠道引导与文档** - copilot: 优化添加渠道引导与文档\n- **优化前端诊断结果展示** - copilot: 优化前端诊断结果展示\n- **增加 OAuth 重试/取消与渠道诊断** - copilot: 增加 OAuth 重试/取消与渠道诊断\n- **补齐 ping、错误分类与 OAuth 状态展示** - copilot: 补齐 ping、错误分类与 OAuth 状态展示\n- **支持 GitHub Copilot OAuth 与 Responses 上游接入** - copilot: 支持 GitHub Copilot OAuth 与 Responses 上游接入\n- **ConversationCard 展示子代理列表与状态摘要** - web: ConversationCard 展示子代理列表与状态摘要\n- **支持流式会话状态追踪** - conversation: 支持流式会话状态追踪\n\n### 修复\n\n- **区分主对话与子代理覆盖状态** - ui: 区分主对话与子代理覆盖状态\n- **支持仅子代理渠道覆盖** - backend: 支持仅子代理渠道覆盖\n- **覆盖 Gemini 内联图并加固图片 token 估算** - utils: 覆盖 Gemini 内联图并加固图片 token 估算\n- **按真实尺寸估算内联 base64 图片的 token** - utils: 按真实尺寸估算内联 base64 图片的 token\n- **跳过空 tool_calls 避免 text block 被拆分** - providers: 跳过空 tool_calls 避免 text block 被拆分\n- **移除折叠态子代理摘要** - frontend: 移除折叠态子代理摘要\n- **按展开状态置顶对话卡片** - ui: 按展开状态置顶对话卡片\n- **修正对话卡片展开展示** - ui: 修正对话卡片展开展示\n- **跳过 Claude Command 文档标题回退** - messages: 跳过 Claude Command 文档标题回退\n- **优化对话卡片展开展示** - ui: 优化对话卡片展开展示\n- **同步单轮主对话预览截断** - desktop: 同步单轮主对话预览截断\n- **优化单轮主对话预览截断** - frontend: 优化单轮主对话预览截断\n- **拆分驾驶舱主对话轮次展示** - frontend: 拆分驾驶舱主对话轮次展示\n- **提供全局提示组件上下文** - desktop: 提供全局提示组件上下文\n- **优化驾驶舱对话 ID 展示** - ui: 优化驾驶舱对话 ID 展示\n- **优化驾驶舱窄屏工具栏布局** - desktop: 优化驾驶舱窄屏工具栏布局\n- **优化渠道搜索框响应式宽度** - channel: 优化渠道搜索框响应式宽度\n- **优化驾驶舱窄屏筛选布局** - frontend: 优化驾驶舱窄屏筛选布局\n- **添加搜索框清除按钮** - desktop: 添加搜索框清除按钮\n- **优化驾驶舱筛选栏布局** - frontend: 优化驾驶舱筛选栏布局\n- **优化驾驶舱对话展示** - frontend: 优化驾驶舱对话展示\n- **精简驾驶舱统计卡片** - frontend: 精简驾驶舱统计卡片\n- **修正子代理总数统计** - frontend: 修正子代理总数统计\n- **过滤重复对话卡片** - frontend: 过滤重复对话卡片\n- **过滤重复渠道索引** - frontend: 过滤重复渠道索引\n- **修正驾驶舱卡片与统计文案** - frontend: 修正驾驶舱卡片与统计文案\n- **延迟加载全局统计图** - desktop: 延迟加载全局统计图\n- **保留快速输入清理后的 URL** - frontend: 保留快速输入清理后的 URL\n- **修正快速输入 URL 清理** - frontend: 修正快速输入 URL 清理\n- **新建/编辑渠道默认开启自动学习上游限速** - frontend: 新建/编辑渠道默认开启自动学习上游限速\n- **用 unref() 包装 store 响应式值** - frontend: 用 unref() 包装 store 响应式值\n- **过滤标题回退中的注入上下文** - backend: 过滤标题回退中的注入上下文\n- **修复托盘开机自启切换状态** - desktop: 修复托盘开机自启切换状态\n- **调整统一会话 ID 提取优先级** - backend: 调整统一会话 ID 提取优先级\n- **更新火山方舟控制台链接** - desktop: 更新火山方舟控制台链接\n- **驾驶舱展开卡片排序稳定** - Web 与桌面端会话驾驶舱在存在展开卡片时固定当前卡片顺序,避免轮询刷新或最新消息更新时间导致卡片跳动;全部收起后恢复按最新消息排序,并补充桌面端排序稳定性测试\n- **兼容 vLLM 响应中的 reasoning 字段** - providers: 兼容 vLLM 响应中的 reasoning 字段\n- **清理会话反馈残留引用** - backend: 清理会话反馈残留引用\n- **移除驾驶舱会话反馈输入框** - ui: 移除驾驶舱会话反馈输入框\n- **移除驾驶舱卡片展开折叠按钮** - ui: 移除驾驶舱卡片展开折叠按钮\n- **调整驾驶舱渠道列表高度** - ui: 调整驾驶舱渠道列表高度\n- **修复 customHeaders 覆盖 Copilot token 的安全漏洞** - copilot: 修复 customHeaders 覆盖 Copilot token 的安全漏洞\n- **修复响应协议转换与认证头泄露** - copilot: 修复响应协议转换与认证头泄露\n- **修复 ConversationChannelSequence Vuetify CSS 颜色变量 alpha 写法** - ui: 修复 ConversationChannelSequence Vuetify CSS 颜色变量 alpha 写法\n- **Compshare 月度额度耗尽自动拉黑** - `extractErrorInfo`/`extractErrorCode` 兼容大写字段名 (`RetCode`/`Message`) 和数字类型错误码;`isInsufficientBalanceMessage` 增加 `monthly limit exceeded` 等精确短语,使 429 + 月度额度耗尽错误能正确触发 key 拉黑\n- **收敛会话卡片高度避免撑爆页面** - cockpit: 收敛会话卡片高度避免撑爆页面\n- **保留原始元数据识别子代理** - conversation: 保留原始元数据识别子代理\n- **增强调试日志定位响应协议** - messages: 增强调试日志定位响应协议\n- **补齐驾驶舱看板文案** - i18n: 补齐驾驶舱看板文案\n- **注册驾驶舱图标** - web: 注册驾驶舱图标\n- **恢复驾驶舱顶部入口** - web: 恢复驾驶舱顶部入口\n\n### 优化\n\n- **优化控制台图表长周期采样** - desktop: 优化控制台图表长周期采样\n- **优化历史图表长周期采样** - frontend: 优化历史图表长周期采样\n\n### 重构\n\n- **清理会话反馈功能** - 清理会话反馈功能\n- **提取 ConversationChannelSequence 组件,消除主对话/子代理渠道列表重复** - ui: 提取 ConversationChannelSequence 组件,消除主对话/子代理渠道列表重复\n- **子代理渠道交互改为行列表,与主对话保持一致** - cockpit: 子代理渠道交互改为行列表,与主对话保持一致\n- **同步驾驶舱 BoardItem 模型到桌面端** - desktop: 同步驾驶舱 BoardItem 模型到桌面端\n- **Dashboard 适配 ConversationBoardItem 结构** - web: Dashboard 适配 ConversationBoardItem 结构\n- **重构驾驶舱子代理分组与聚合逻辑** - web: 重构驾驶舱子代理分组与聚合逻辑\n- **抽取驾驶舱列分组工具** - web: 抽取驾驶舱列分组工具\n\n### 测试\n\n- **补齐 vLLM reasoning 兼容覆盖** - providers: 补齐 vLLM reasoning 兼容覆盖\n- **强化 Gemini 图片估算测试并提速** - utils: 强化 Gemini 图片估算测试并提速\n- **补齐 copilot 包 httptest 覆盖** - copilot: 补齐 copilot 包 httptest 覆盖\n\n### 文档\n\n- **记录 Claude Code v2.1.193 变更评估** - todo: 记录 Claude Code v2.1.193 变更评估\n- **完善兼容选项提示** - frontend: 完善兼容选项提示\n- **记录 Codex 上游变更评估** - todo: 记录 Codex 上游变更评估\n- **更新 CHANGELOG 记录 Compshare 月度额度拉黑修复** - 更新 CHANGELOG 记录 Compshare 月度额度拉黑修复\n\n### 其他\n\n- **升级前端依赖** - deps: 升级前端依赖\n- **更新 Go 与 Wails 相关依赖** - deps: 更新 Go 与 Wails 相关依赖\n\n## [v2.9.17] - 2026-06-23\n\n### 新增\n\n- **桌面端会话父子关系导航** - 展示会话父子关系导航,便于在关联会话间跳转\n- **关联子代理会话记录** - 支持记录与展示子代理关联会话\n- **桌面端会话驾驶舱任务看板** - 改造会话驾驶舱任务看板,增强子代理任务概览\n- **桌面端独立驾驶舱入口** - 添加独立驾驶舱入口,提升访问效率\n- **会话反馈记录** - 支持记录会话反馈信息\n- **驾驶舱子代理看板改进** - 改进 cockpit subagent board 展示能力\n\n### 修复\n\n- **统计图表长跨度采样与展示** - 将 Web 与桌面端全局/渠道统计图默认采样点控制在 200 以内,并修复渠道级图表在仅最近几天有数据时选择 1 年导致曲线被全年空白压缩的问题\n- **桌面端能力测试渠道复制反馈** - 改进能力测试渠道复制反馈体验\n\n### 重构\n\n- **桌面端侧边栏导航分组拆分** - 拆分侧边栏导航分组,优化导航结构\n- **桌面端驾驶舱入口分离** - 将驾驶舱入口从管理控制台分离\n\n### 文档\n\n- **已完成 TODO 清理** - 移除已完成 TODO 项\n\n### 其他\n\n- **统计图表采样展示修复合并** - 合并统计图表采样展示修复\n- **桌面端开发模式启动顺序** - 调整开发模式前端启动顺序\n- **桌面端开发壳构建任务** - 添加开发壳构建任务\n\n## [v2.9.16] - 2026-06-23\n\n### 修复\n\n- **Docker 共享前端工具嵌入** - Docker 构建包含共享前端工具文件,避免容器构建缺失共享工具\n\n## [v2.9.15] - 2026-06-23\n\n### 新增\n\n- **Kimi K2.7 能力别名** - 添加 Kimi K2.7 模型能力别名,支持默认模型能力识别\n- **Qwen3.7 模型阶梯定价** - 添加 Qwen3.7 模型阶梯定价配置\n- **Codex tool_search_output 合并** - Responses 支持合并 Codex `tool_search_output` 工具输出\n- **桌面端模型下拉自动滚动定位** - 添加模型下拉列表自动滚动定位功能\n- **版本升级构建监控** - version-bump 技能新增构建进度监控流程\n\n### 修复\n\n- **Codex Responses 默认模型** - 更新 Codex Responses 共享预设默认模型\n- **桌面端渠道中心独立滚动** - 修复渠道中心滚动区域交互\n- **能力检测重试一致性** - 修复 capability retry consistency\n- **桌面端能力检测提示层裁剪** - 修复能力检测模型提示层被裁剪的问题\n- **MiMo Codex 兼容预设** - 开启 MiMo Codex 兼容预设\n- **桌面端源模型预设过滤** - 过滤已配置的源模型预设选项\n- **桌面端目标模型下拉滚动定位** - 修复目标模型下拉滚动定位\n- **桌面端目标模型完全匹配显示** - 修复目标模型完全匹配时不显示窗口的问题\n- **桌面端源模型选择展开** - 修复源模型选择后再次点击不展开列表的问题\n- **桌面端源模型下拉显示** - 修复源模型下拉列表不显示的问题\n- **桌面端模型重定向空胶囊** - 移除模型重定向源模型输入框的空胶囊元素\n\n### 重构\n\n- **渠道协议预设抽取** - 抽取渠道协议预设\n- **Codex Responses 共享预设抽取** - 抽取 Codex Responses 共享预设\n- **前端模型优先级排序共享** - 共享模型优先级排序逻辑\n\n### 测试\n\n- **用户额度不足黑名单用例** - 补充用户额度不足黑名单用例\n\n## [v2.9.14] - 2026-06-22\n\n### 新增\n\n- **Token 统计时间范围扩展到 1 年** - 新增 90 天、半年、1 年、今年等长时间范围选项\n - 后端数据保留期默认值:30 天 → 366 天,上限:90 天 → 366 天(闰年全年覆盖)\n - API 时间范围上限:30 天 → 366 天\n - 新增时间格式支持:`90d`、`180d`、`365d`、`thisyear`\n - 聚合间隔优化:长时间范围使用 8-12 小时间隔,控制数据点数量\n - Web 端和桌面端所有图表组件同步更新\n - 统一两端时间选项文案:桌面端 `7d`/`30d` → `1周`/`1月`\n - 新增中文、英文、印尼文三语言国际化文案\n- **Unity2.ai 赞助商集成** - 新增 Unity2.ai 赞助商渠道预设与国际化文案支持\n- **Unity2.ai 国际化文案** - 添加 Unity2.ai 渠道预设翻译\n- **自动赞助商集成技能** - 新增 add-sponsor skill,支持自动化赞助商集成流程\n- **对齐 Web 与桌面端渠道菜单行为** - 统一两端渠道操作菜单交互逻辑\n\n### 修复\n\n- **Codex Responses 共享预设默认模型** - 将 Kimi 默认模型同步为 `kimi-k2.7` 并补齐能力注册,GLM 默认模型升级为 `glm-5.2`,避免 Web/desktop 一键预设回退到旧模型\n- **Unity2.ai 和 RunAPI 响应剥离图片生成工具** - 为 Unity2.ai 和 RunAPI 响应剥离 image generation tool\n- **Unity2.ai 使用原生 responses 协议** - Unity2.ai 改用原生 responses 协议\n- **Unity2.ai 添加 anthropic plan 支持 Messages 目标** - 为 Unity2.ai 添加 anthropic plan 以支持 Messages target\n- **Unity2.ai targets 顺序修正** - 修正 Unity2.ai targets 顺序为标准 Messages → Responses → Chat\n- **恢复原生 tool_search 调用** - 恢复 Codex 原生 tool_search calls\n- **为 tool_search stream 发送 custom_tool_call_input.done** - 确保 tool_search 流式响应完整性\n- **保留 tool_search 历史参数** - 保持 Codex tool_search history arguments\n- **恢复 tool_search 自定义调用语义** - 恢复 tool_search custom call semantics\n- **统一两端高级选项选择框宽度与布局** - 统一 Web 和桌面端渠道编辑器高级选项布局\n- **tool_search 保留原始参数 schema 映射为 function** - converters 中 tool_search 保留原始参数 schema 映射\n- **统一渠道菜单文案与 Web 端** - 桌面端渠道菜单文案对齐 Web 端\n- **保留搜索结果渠道置顶置底操作** - 桌面端搜索结果中保留渠道排序操作\n\n### 重构\n\n- **预设配置辅助函数抽取** - 使用 helper functions 减少 preset 配置重复代码\n\n### 测试\n\n- **Codex chat 子代理工具调用测试覆盖** - 为 chat sub-agent tool calls 添加测试覆盖\n\n### 文档\n\n- **更新 add-sponsor skill 文档** - 更新 add-sponsor skill 最新使用模式\n\n### 其他\n\n- **移除 add-sponsor.sh 脚本** - 删除旧的 add-sponsor shell 脚本\n\n## [v2.9.13] - 2026-06-21\n\n### 新增\n\n- **子代理感知对话路由与亲和隔离** - 新增 subagent-aware conversation routing 与 affinity isolation,减少不同子代理会话间的路由串扰\n- **额外代理访问密钥** - 支持额外代理访问密钥配置,扩展代理入口鉴权能力\n- **内置模型预设匹配改用正则表达式** - builtin model registry 的 patterns 从简单通配符(`claude-opus-4-7*`)改为显式正则,精确控制前后边界,避免 `gpt-5.4-mini` 被 `gpt-5.4` 误吃、`glm-5.1` 被 `glm-5` 误吃等歧义;支持厂商前缀(`bedrock/claude-opus-4-7`、`xxx-claude-opus-4-7`)和日期版本号后缀(`-20260101`),同时拒绝 `claude-haiku-4-5-with-claude-opus-4-7-fallback` 这类异常组合名\n - 一个 entry 支持多个 pattern(如 `kimi-for-coding` 别名回退到 `kimi-k2.7-code`),未来 Kimi 升级到 K2.8 只需把别名移到新 entry\n - 前端 `channelPayload.ts`、桌面端 `channel-payload.ts` 匹配函数改用 `new RegExp(pattern, 'i')`\n - 后端 `model_registry.go` 新增 `builtinPatternCache` 预编译缓存,RE2 兼容剥离 `(?=$|@)` lookahead 后手动检查边界;`resolvePatternValueFold` 先正则再通配符\n - generator 加构建时正则校验\n - **用户路由级 supportedModels 配置不受影响**,继续用通配符语义\n- **手动暂停与自动熔断区分显示** - UI 区分手动暂停与自动熔断状态展示\n- **images 渠道纳入模型聚合** - 模型列表聚合纳入 images 渠道,并支持多渠道并行收集聚合\n- **Key 权重与模型选择** - Key 池新增 Weight/Models 选择能力,支持 metadata restore 测试并在前端加载 `apiKeyConfigs`\n- **按 Key 凭证池与限速** - 新增 per-key credential pool,支持按 Key 作用域进行限速\n\n### 修复\n\n- **调度器文件拆分冲突修复** - 解决 worktree 合并后的 scheduler file-split 冲突\n- **驾驶舱交互与 Key 管理** - 修复桌面端驾驶舱交互与 Key 管理若干问题\n- **驾驶舱暂停/熔断状态徽标区分** - 区分暂停与熔断状态 badge 并统一文案\n- **驾驶舱渠道选择交互** - 修复驾驶舱渠道选择交互问题\n- **渠道统计图表交互对齐** - 对齐渠道统计图表交互逻辑\n- **重复 Key 提示** - 添加重复 Key 时给出明确提示\n- **桌面 dashboard 控制台交互** - 修复桌面端 dashboard console interactions\n- **Responses 转换保持 Codex 工具兼容** - preserve Codex tool compatibility in responses conversion\n- **Dashboard 渠道编辑弹窗** - 修复桌面端 dashboard 渠道编辑弹窗问题\n- **限速高水位调度优化** - 优化调度器限速高水位策略\n- **渠道统计包含黑名单 Key** - channel stats 纳入 blacklisted keys\n- **渠道编辑对话框高度** - 优化渠道编辑对话框高度\n- **黑名单快照与配置监听** - 加固 blacklist snapshot 与 config watcher\n- **Code review 反馈问题修复** - 修复 code review 确认的 11 个 keypool/ratelimit/config 问题\n- **ChannelView 返回 apiKeyConfigs** - BuildChannelView 返回 `apiKeyConfigs`\n- **模型映射同步后再能力测试** - 前端 capability test 前同步 model mapping\n\n### 重构\n\n- **配置更新逻辑抽取** - 抽取 `applyAPIKeyConfigUpdate` 并改进 watcher 防抖策略\n- **前端活动可视化与单测** - 改进 activity visualization 并补充 unit tests\n- **ChannelOrchestration 样式拆分** - 从 `ChannelOrchestration.vue` 抽取样式\n- **EditChannelModal 样式与优先级逻辑拆分** - 从 `EditChannelModal.vue` 抽取样式和 model-priority 逻辑\n- **App 样式与能力测试组合式函数拆分** - 从 `App.vue` 抽取样式和 capability-test composable\n- **ChannelOrchestration 活动组合式函数抽取** - 抽取 `useChannelActivity` composable\n- **Responses handler 拆分** - 拆分 `handler.go`(2160 行 → 5 个文件)\n- **Chat handler 拆分** - 拆分 `handler.go`(2305 行 → 5 个文件)\n- **Scheduler 测试拆分** - 拆分 `channel_scheduler_test.go`(1650 行 → 3 个文件)\n- **Scheduler 实现拆分** - 拆分 `channel_scheduler.go`(1535 行 → 4 个文件)\n- **通用 stream helper 拆分** - 拆分 `handlers/common/stream.go`(2799 行 → 4 个文件)\n- **Metrics 实现拆分** - 拆分 `channel_metrics.go`(3731 行 → 5 个文件)\n\n### 文档\n\n- **待办事项更新** - 更新 TODO 文档\n\n### 其他\n\n- **额外代理访问密钥合并** - 合并额外代理访问密钥支持\n- **渠道编辑对话框高度修复合并** - 合并渠道编辑对话框高度修复\n- **黑名单 Key 统计修复合并** - 合并 blacklisted key stats fix\n- **Go 格式化** - 执行 go fmt 格式化\n\n## [v2.9.12] - 2026-06-19\n\n### 修复\n\n- **上下文窗口按输入 token 过滤** - 调度器按输入 token 过滤 context windows,避免选择不满足输入上下文要求的渠道\n- **Docker 前端嵌入构建稳健性改进** - 改进 Docker frontend embed build robustness,提升前端产物嵌入构建稳定性\n\n## [v2.9.11] - 2026-06-19\n\n### 修复\n\n- **桌面控制台 fallback 文案本地化** - 桌面端控制台 fallback labels 使用多语言文案,避免未翻译标签展示\n\n## [v2.9.10] - 2026-06-19\n\n### 新增\n\n- **上游认证头覆盖配置** - 后端新增 upstream auth header override,支持按渠道覆盖上游认证头\n- **渠道认证头设置入口** - 前端暴露 channel auth header 设置,支持管理界面配置认证头覆盖\n- **OpenCode Go 预设 x-api-key** - 桌面端 OpenCode Go 预设自动设置 x-api-key 认证头\n\n### 修复\n\n- **模型能力标签简化** - 简化前端 model capability labels,改善模型能力展示文案\n\n### 其他\n\n- **SignPath artifact 安全暂存** - SignPath artifacts 使用安全名称暂存,避免签名流程中的产物名称冲突\n\n## [v2.9.9] - 2026-06-19\n\n### 其他\n\n- **SignPath 签名步骤唯一 artifact 名称** - 为 SignPath 签名步骤添加唯一 artifact 名称,避免多平台并行构建时名称冲突\n\n## [v2.9.8] - 2026-06-19\n\n### 新增\n\n- **OpenCode Zen/Go 渠道预设更新至 glm-5.2** - 桌面端 OpenCode Zen/Go 预设的 Claude 与 Codex 目标模型映射更新为 glm-5.2 / deepseek-v4-flash,并补齐对应 reasoning 映射\n\n### 修复\n\n- **视觉回退模型上下文能力自动添加** - 前端和桌面端在上下文能力自动添加时纳入视觉回退模型,并按大小写不敏感方式去重,避免回退模型缺失能力配置行\n- **前端 lint 存量问题清理** - 修复 Web 前端 ESLint 存量错误与警告,清理编辑渠道弹窗拆分后的未使用旧逻辑并补齐相关类型声明\n- **Responses SSE 扫描缓冲区上限提升** - 将 Responses 流式 SSE 单行扫描上限提升至 32MB,避免图片/产物等大事件被截断导致流式失败\n\n### 其他\n\n- **upstream-check seen 标签数量限制** - 限制 upstream-check skill 记录的 seen upstream tags 数量,避免状态无限增长\n\n## [v2.9.7] - 2026-06-19\n\n### 新增\n\n- **模型响应模态信息增强** - 增强 models 接口响应中的 modalities 信息\n\n### 修复\n\n- **前端模型目标建议修复** - 修复前端模型 target suggestions 展示与匹配问题\n- **发布暂存目录可见性修复** - 使用可见 release staging directory,修复发布打包暂存路径问题\n\n## [v2.9.6] - 2026-06-19\n\n### 修复\n\n- **移除 agent profile 窗口硬最低限制** - 移除桌面端 agent profile 窗口硬最低限制,新增上下文估算日志\n- **简化模型能力定价编辑器** - 简化前端 model capability pricing editor 交互\n- **修复 capability test 模型映射复用** - 修复 capability test 模型映射复用问题\n- **优化模型能力行布局** - 优化桌面端 model capability row layout\n- **改善 capability test 状态处理** - 改善桌面端 capability test status handling\n- **同步桌面端模型能力配置** - 同步桌面端 model capabilities 配置\n\n## [v2.9.5] - 2026-06-19\n\n### 新增\n\n- **桌面端模型能力编辑器改进** - 改进桌面端 model capability editor 交互与布局\n- **前端模型能力编辑器改进** - 改进前端 model capability editor 交互与布局\n- **DeepSeek V4 能力更新** - 更新 model registry 中 DeepSeek V4 模型能力信息\n\n### 其他\n\n- **SignPath 测试签名校验放宽** - 放宽 SignPath test signature validation,提升测试环境签名校验兼容性\n\n## [v2.9.4] - 2026-06-19\n\n### 新增\n\n- **上游模型能力注册表** - 新增 upstream model capability registry,用于集中维护上游模型能力信息\n- **上下文感知渠道路由** - 新增 context-aware channel routing,根据请求上下文与模型能力选择更合适的渠道\n\n### 修复\n\n- **桌面端目标模型选项来源修复** - 桌面端使用已拉取的目标模型选项,避免候选模型与上游能力不一致\n- **桌面端渠道能力测试流程对齐** - 对齐桌面端渠道 capability test 流程,提升测试结果一致性\n- **桌面端渠道重排序类型处理修复** - 修复桌面端 channel reorder 的类型处理问题\n\n### 其他\n\n- **SignPath 签名集成** - CI 集成 SignPath signing,完善发布签名流程\n\n## [v2.9.3] - 2026-06-18\n\n### 新增\n\n- **讯飞星辰渠道预设** - 新增 ProviderXFyun 预设,Anthropic + OpenAI 双入口,支持 Messages/Chat/Responses 三目标,含快速输入 URL 识别与中英文 locale\n- **Codex SQLite 迁移修复 visibility 字段** - migrateCodexStateDB 动态检测 threads 列,自动回填 preview/has_user_event/thread_source 等隐藏线程可见性字段\n- **中转站错误码 failover 识别增强** - 增强中转站错误码识别与黑名单判定,提升故障转移覆盖\n- **渠道运行态冷却与上游账号池降级** - 调度器新增渠道运行态冷却与上游账号池不可用降级策略\n- **Compshare glm-5.2 预设与推理映射同步** - 桌面端 ChannelEditDialog 同步 Compshare glm-5.2 预设与 reasoning 映射\n- **火山方舟 Compshare 预设更新至 glm-5.2** - 更新桌面端火山方舟 Compshare 渠道预设模型\n\n### 修复\n\n- **渠道日志按稳定渠道名过滤共享 metricsKey** - GetChannelLogs 对共享 metricsKey 优先按创建时渠道名筛选,旧日志回退 channelIndex,避免删除/重排后日志串台\n- **Codex SQLite visibility 回填来源保护** - 迁移 threads 时根据 source 列跳过 exec 等后台线程,避免误标记为用户会话\n- **trace 亲和性遵守 SupportedModels** - 新增测试验证亲和渠道不支持当前模型时正确跳过并回退优先级选择\n- **共享 metricsKey 渠道日志按渠道名归属** - 修复共享 metricsKey 场景下渠道日志归属到稳定渠道名\n- **共享 metricsKey 渠道日志按 channelIndex 过滤** - 修复渠道日志在共享 metricsKey 时按 channelIndex 过滤\n- **短流 EOF 重试策略** - 在 message_stop 前遇到短流 EOF 时进行重试\n- **新建渠道使用生成名称提交** - 修复桌面端新建渠道提交时未使用生成名称的问题\n\n### 文档\n\n- **CHANGELOG 维护更新** - 更新 CHANGELOG,补充讯飞星辰、Codex 迁移修复、日志过滤与调度测试记录\n\n## [v2.9.2] - 2026-06-18\n\n### 新增\n\n- **火山方舟预设模型与推理映射更新** - 更新桌面端火山方舟渠道预设模型与 reasoning 映射\n- **渠道卡片缓存命中率展示** - 桌面端渠道卡片新增缓存命中率显示\n- **渠道添加偏好卡片式交互** - 渠道添加流程改为卡片式交互,提升偏好选择体验\n- **渠道日志 reasoning effort 展示同步** - 桌面端渠道日志同步展示 reasoning effort 信息\n- **渠道支持选择队列末尾并记住偏好** - 前端新增渠道支持放到队列末尾,并记住用户偏好\n- **CompactionV2 本地 compact 与 SSE 转换** - Responses 实现 CompactionV2 本地压缩并支持 SSE 转换\n- **Passthrough converter ResponsesItem 字段映射补全** - passthrough converter 补齐 ResponsesItem 全量字段映射\n- **ResponsesItem EncryptedContent 字段** - 类型定义新增 EncryptedContent 字段以支持 compaction\n- **视觉回退 reasoning effort 与 output_config.effort 同步** - 视觉回退模型支持 reasoning effort,并同步 output_config.effort\n- **渠道日志 reasoning effort chips 展示** - 前端渠道日志弹窗新增 reasoning effort chips\n- **视觉回退 reasoning effort 选择器与多语言文案** - 前端新增视觉回退 reasoning effort selector 和 locale keys\n- **Claude 渠道 reasoning mapping 支持** - 前端扩展 reasoning mapping 支持到 claude channel type\n- **Claude thinking effort 渠道映射注入** - providers 根据渠道 reasoningMapping 注入 Claude thinking effort\n- **ChannelLog reasoning effort 字段** - metrics 新增 originalReasoningEffort 与 actualReasoningEffort\n- **渠道日志 reasoning effort 提取 helpers** - handlers 新增 reasoning effort 提取辅助函数\n\n### 修复\n\n- **火山方舟 Responses 视觉回退补齐** - 桌面端补齐火山方舟 Responses 视觉回退配置\n- **orchestration promotion 标签文案优化** - 优化桌面端 orchestration promotion 标签文案\n- **legacy reasoning params 转换顺序修复** - providers 在重写为 block format 前先转换 legacy reasoning params\n- **模型映射 noVision 同步修复** - 按目标模型改进 noVision 在 model mappings 间的同步逻辑\n- **模型映射 noVision flag 同步修复** - 修复 noVision flag 按 target 在 model mappings 间同步\n- **Add/Edit Channel Modal 缺失导入修复** - 补充 AddChannel/EditChannel modals 中 normalizeSelectableString 导入\n- **Chat reasoning effort 映射模型来源修复** - Chat 使用实际请求体 model 进行 reasoning effort mapping\n- **重复 reasoning effort 标签合并** - 合并 UI 中重复的 reasoning effort 标签显示\n\n### 重构\n\n- **EditChannelModal 请求 URL 构建统一** - 统一 EditChannelModal 使用 buildExpectedRequestUrls\n- **AddChannelModal 快速添加职责收敛** - 移除 AddChannelModal 编辑模式,仅保留 quick-add\n- **reasoning effort 校验逻辑统一** - config 使用 isValidReasoningEffort 统一 reasoning effort validation\n\n## [v2.9.0] - 2026-06-17\n\n### 新增\n\n- **运行时超时热更新与 responseHeaderTimeout 渠道字段** - 新增 `responseHeaderTimeoutMs` 渠道字段,支持全局运行时超时热更新,httpclient 支持显式 responseHeaderTimeout\n- **熔断器/调校台新增超时滑块** - 桌面端和前端熔断器、调校台新增 `requestTimeoutMs` 与 `responseHeaderTimeoutMs` 运行时滑块\n- **CompactModel 配置支持** - Responses 渠道新增 `compactModel` 配置字段,支持压缩模型自动选择\n- **内联模型映射编辑器** - 所有渠道类型支持内联编辑模型映射,无需弹窗\n- **主动限速滑动窗口算法** - 令牌桶改为滑动窗口算法,支持可配置的窗口时长,自动学习上游限速默认开启\n- **b64_json 图片响应自动转换** - 支持 `b64_json` 请求的 URL 响应自动转换\n- **向下游下发可读的空响应错误文案** - 流式响应场景向客户端下发可读错误信息而非空流\n- **桌面端 App 级 polling 调度层** - ConversationDashboard 轮询迁移至 App 级调度,支持 window visibility 事件控制\n- **桌面端图表功能对齐 Web 端** - 桌面端控制面板使用统计图表与 Web 端完全对齐\n- **渠道活动图表背景** - ChannelCard 新增活动图表背景可视化\n- **渠道快速创建卡片上游类型品牌色与图标** - 快速创建渠道卡片的上游类型展示品牌色和对应图标\n- **渠道编辑器输出冗长度配置** - 渠道编辑器新增输出冗长度配置选项\n- **驾驶舱空闲自动恢复时间持久化配置** - 支持通过环境变量和前端选择器持久化配置 override 有效期\n- **Kimi Coding Plan 模型重定向与智能排序** - Kimi 渠道实现 Coding Plan 模型重定向与智能模型排序\n- **桌面端模型重定向区域 premium UI 重构** - 目标模型输入改为自定义下拉菜单,支持下拉建议\n- **快速模式与历史图片轮次限制配置** - Web 端补全快速模式与历史图片轮次限制配置\n- **流式断流超时预设快捷按钮** - 桌面端新增流式断流超时预设快捷按钮\n- **Esc 快捷键提示** - Web 和桌面端为对话框关闭按钮添加 Esc 快捷键提示\n- **Compatibility 分组高级开关补全** - Web 端和桌面端补全 Compatibility 分组所有缺失的高级开关\n- **桌面端 i18n 全面国际化** - 迁移到 vue-i18n + 外部 JSON 语言文件,桌面端渠道编辑、控制台、图表等全面 i18n 化\n- **文档: Geo SEO, llms.txt, README 桌面安装说明** - 新增 Geo SEO、llms.txt,改进贡献指南和 README 桌面安装说明\n\n### 修复\n\n- **临时限流不触发 Key 黑名单** - 临时限流场景不再误将 Key 加入黑名单\n- **REQUEST_TIMEOUT 默认值从 300s 调整为 120s** - 调整默认请求超时为更合理的 120 秒\n- **保留 web_search 工具以兼容 Codex v0.139.0+** - Responses 场景保留 web_search 工具兼容新版 Codex\n- **修复 UpstreamUpdate JSON 标签拼写错误** - 修正配置结构体 JSON tag 拼写\n- **图表系列修复** - 修复 KeyTrendChart yaxis 绑定、series 复用、forceNiceScale、失败率柱透明度等多项图表问题\n- **模型映射编辑修复** - 修复 modelMapping 对象值保存失败、combobox 值规范化、目标模型输入框抖动等问题\n- **限速配置修复** - 修复 0 值显示为空、窗口时长清空后无法保存、rateLimitWindowMinutes 字段遗漏等\n- **桌面端控制台组件修复** - 修复组件缺失导入、滚动导航、Esc 键 window 全局监听等\n- **i18n 修复** - 修复英文模式下统计图表中文、id.json 时长单位错误、useI18n setup 上下文时序等\n- **resumeChannel 后自动调用 setStatus('active')** - 恢复渠道后正确更新状态\n- **REQUEST_TIMEOUT/RESPONSE_HEADER_TIMEOUT 降级为启动兜底** - 超时配置运行时以调校台为准,环境变量仅作启动兜底\n\n### 重构\n\n- **渠道编辑器全面重构** - 左侧导航+右侧面板布局,拆分快速添加与编辑弹窗,统一内容顺序对齐 WebUI\n- **高级选项分组重构** - 创建 Runtime 运行期策略分组,统一布局结构,主动限速拆分为独立 RateLimitGroup\n- **模型映射区域深度重构** - 行内可编辑、视觉回退模型行内开关驱动,优化布局与样式\n- **快速添加面板重构** - 紧凑两栏布局、卡片式分块布局、上游类型选择器标题行后缀推荐项\n- **渠道卡片状态展示重构** - 统一交互逻辑、展开按钮与统计图表折叠优化\n- **熔断器/调校台滑块设计重构** - 参考 Web 端样式,增强可见性和对比度\n- **桌面端渠道编辑创建流程重构** - 统一使用 t 多语言函数,对齐内容顺序与 WebUI\n- **vue-i18n 迁移** - 从自定义 i18n 迁移到 vue-i18n + 外部 JSON 语言文件,统一两端 key 命名空间\n- **ChannelManager/ChannelTab 惰性加载** - 非活跃页跳过刷新,提升性能\n- **CircuitBreakerDialog 布局重构** - header/footer 固定,body 独立滚动\n\n### 文档\n\n- **新增 RESPONSE_HEADER_TIMEOUT 与运行时超时配置说明**\n- **更新 compactModel 说明,明确 Responses 渠道 failover 机制**\n\n## [v2.8.28] - 2026-06-11\n\n### 新增\n\n- **桌面端管理面板集成用量统计图表 (#199)** - 管理面板新增用量统计图表展示\n- **补齐渠道中心所有 provider 到 Agent 直连设置** - 完善 Agent 直连配置,覆盖所有渠道中心 provider\n- **驾驶舱 Override 有效期选择器补齐** - 驾驶舱 Override 配置界面新增有效期选择器\n- **upstream-check 新增功能与体验变更报告输出步骤** - 上游版本检查 skill 新增变更报告输出\n\n### 修复\n\n- **完善驾驶舱 i18n 并移除重复的系统状态显示** - 优化驾驶舱国际化文案,移除重复状态展示\n- **补全 tooltip content-class 并加大快捷键提示间距** - 修复 tooltip 样式类缺失,优化快捷键提示视觉间距\n- **补全 stream.go 中 message_delta 的 stop_details 字段** - 流式响应 handlers 补充 stop_details 字段处理\n- **添加 stop_details 字段到 message_delta** - providers 层新增 stop_details 字段支持\n- **过滤 compaction 流式响应中的 reasoning items (#179)** - Responses compaction 场景过滤推理内容,避免重复输出\n- **优化弹窗快捷键提示体验** - 改进弹窗快捷键提示交互\n- **Override 有效期标签改为用户易懂的「空闲自动恢复」** - 重写 Override 配置文案,提升用户理解\n- **有效期选择器默认选中 30min + 补充长时段选项** - 优化有效期默认值与选项范围\n- **修复磁贴图标透明背景与黄色光圈,补齐 Windows 磁贴资产** - 修复桌面端 Windows 图标透明度问题,补全磁贴资源\n- **千帆 Coding Plan 渠道补充模型映射与特性配置** - 完善千帆编码计划渠道模型映射\n- **火山方舟 Coding Plan 渠道补充模型映射与特性配置 (#204)** - 完善火山方舟编码计划渠道模型映射\n- **替换已归档官方镜像为社区维护 fork,修复 Docker API 版本兼容性问题** - Watchtower 迁移至社区维护镜像,修复 API 兼容性\n\n### 优化\n\n- **前端构建增加双层缓存,避免重复编译** - 前端构建流程新增缓存机制,加速构建\n\n### 改进\n\n- **驾驶舱 Override 有效期可配置** - 支持环境变量 `OVERRIDE_TTL_MINUTES`(默认 30 分钟)和前端下拉选择器(15min/30min/1h/2h/永不恢复)自定义 override 有效期\n- **Override Idle 续期** - 对话活跃时自动续期 override TTL,仅在闲置超过设定时间后才恢复默认调度\n- **Override 永不恢复选项** - 支持设置 override 永不过期,手动恢复前不会自动过期\n\n### 文档\n\n- **注释说明极易云 originrouter 刻意不纳入 Agent 直连** - 渠道预设注释补充设计决策说明\n- **新增 TODO #199 桌面端用量显示与 #179 compaction v2 think 拆分问题** - 更新待办事项\n- **完成 TODO #1 OpenRouter 免费路由工具调用排查,确认为上游限制** - 标记已完成任务\n- **清理已完成任务并添加三个新 TODO** - 维护任务列表\n\n### 变更文件\n\n- `backend-go/internal/conversation/override.go` - IsPerpetual、per-call duration、RefreshOverrideForUser、SetDefaultTTL\n- `backend-go/internal/conversation/override_test.go` - 新增 8 个测试用例\n- `backend-go/internal/config/env.go` - 新增 OVERRIDE_TTL_MINUTES 环境变量\n- `backend-go/main.go` - 读取环境变量替换硬编码 30 分钟\n- `backend-go/internal/handlers/conversation_handler.go` - API body 新增 duration 参数,响应新增 isPerpetual\n- `backend-go/internal/scheduler/channel_scheduler.go` - SelectChannel 成功消费 override 时调用 RefreshOverrideForUser\n- `frontend/src/components/ConversationDashboard.vue` - 新增 override 持续时间下拉选择器\n- `frontend/src/components/ConversationCard.vue` - 永久 override 展示逻辑\n- `frontend/src/services/api-types.ts` - SequenceOverrideInfo 新增 isPerpetual\n- `frontend/src/services/api.ts` - setConversationOverride 新增 duration 参数\n- `frontend/src/i18n/messages-{zh-cn,en,id}.ts` - 新增 4 个 i18n key\n\n## [v2.8.27] - 2026-06-10\n\n### 新增\n\n- **桌面端 MiniMax M3 渠道预设** - 新增 MiniMax M3 channel presets\n- **桌面端讯飞星辰 MAAS 直连支持** - 添加讯飞星辰 MAAS 桌面端 Agent 直连支持\n- **弹窗快捷键提示与确认交互** - 所有弹窗添加 ESC 取消与 Cmd/Ctrl+Enter 确认快捷键提示,提升操作效率\n\n### 修复\n\n- **Responses serviceType claude 路径认证修复** - serviceType claude 路径正确使用 x-api-key 认证头\n- **桌面端 OpenCode 讯飞星辰直连精确移除** - 仅移除 OpenCode 的讯飞星辰直连,保留 Claude/Codex 路径\n- **Fable reasoning 迁移补齐 model mapping** - migrate fable reasoning mapping with model mapping\n- **REWRITE_RESPONSE_MODEL 支持 Responses API** - Responses API 场景正确支持 REWRITE_RESPONSE_MODEL 重写\n\n### 重构\n\n- **统一 codex responses 预设 source key** - 统一 codex responses 预设 source key 为 codex/gpt/mini\n\n### 其他\n\n- **Responses provider 认证头 serviceType 分支测试** - 添加 Responses provider 认证头 serviceType 分支测试用例\n\n## [v2.8.26] - 2026-06-10\n\n### 新增\n\n- **Claude Fable 模型支持** - 新增 Claude Fable 系列模型映射与兼容处理,升级时自动补齐 Fable 模型映射配置\n- **渠道级主动限速与上游 rate limit header 自动追踪 (#190)** - 新增渠道级令牌桶限速、并发控制与 cooldown 机制,自动解析上游 `X-RateLimit-*` / `Retry-After` 响应头并动态调整限速参数,MiMo 默认 RPM=80\n- **历史图片轮次限制与占位符替换** - 新增渠道级历史图片轮次限制配置(`historicalImageTurnLimit`),超过指定轮次的历史对话图片自动替换为 `[Image]` 占位符,避免不必要的 vision 回退模型切换。覆盖 Claude Messages、OpenAI Chat、Responses API、Gemini 四种协议格式,Web/桌面渠道编辑 UI 支持按渠道配置\n- **渠道体验增强与 Messages 渠道级 CCH 开关** - Web/桌面渠道列表新增 15 分钟窗口”缓存写偏高”提醒 badge;桌面端 RunAPI messages 预设默认关闭 `normalizeMetadataUserId`;`stripBillingHeader` 从全局设置下沉为 messages 渠道级开关,默认关闭,并同步补齐 Web/桌面渠道编辑表单与老配置迁移逻辑\n- **桌面端 OpenRouter 与 ModelScope 渠道预设** - 新增 OpenRouter、ModelScope 渠道中心预设,并将 Kimi Code Plan 合并入 Kimi 预设作为编码计划条目,添加到 Claude/Codex/OpenCode 直连配置列表\n- **桌面端渠道预设扩充** - 新增 RunAPI 赞助商预设、Tencent TokenHub 预设与国内 coding-plan 网关预设,统一 RunAPI 与 Token Plan 文案,优化 RunAPI 渠道预设排序\n- **桌面端管理面板 Fuzzy 开关与熔断器配置** - 管理面板工具栏新增 Fuzzy 模式开关和熔断器配置按钮,熔断器配置预设值、slider 范围与 WebUI 同步对齐\n- **桌面端 Codex/Responses 分层排障诊断** - 在桌面 Agent 页 Codex 卡片新增分层排障,按顺序检查”Codex 配置一致性 → Responses 渠道可用性 → 最近失败请求”,帮助定位 #156 类”本地网关可达但请求失败”的问题。后端扩展 `AgentConfigStatus`(新增 `authMode`/`configConsistent`/`diagnosticCode`/`diagnosticMessage`),在 `getCodexStatus` 中识别 CCS 等工具导致的 `config.toml` 与 `auth.json` 不一致(缺 key、auth_mode 不匹配、插件模式缺 bearer token、旧式配置残缺、配置污染等);前端新增 `useResponsesDiagnostics` composable,聚合 Codex 配置层、Responses 渠道层(无渠道/全禁用/无 key/熔断/协议错配)与最近失败日志层(401/403、429、5xx、超时)的诊断结论,并在状态页提供最高优先级问题摘要。第一版仅给出诊断与手动修复建议,不做自动修复。涉及 `desktop/internal/configservice/service.go`、`desktop/frontend/src/composables/useResponsesDiagnostics.ts`、`desktop/frontend/src/components/agent/*`、`desktop/frontend/src/components/status/StatusTab.vue`、`desktop/frontend/src/i18n/messages.ts`、`desktop/frontend/src/types/index.ts`\n- **桌面端 dashboard 显示运行态当前渠道** - 桌面端 dashboard 新增运行态当前渠道信息展示\n- **简化前端添加渠道流程** - 优化前端渠道添加交互流程\n\n### 修复\n\n- **桌面端 Codex 排障忽略 UI 选择的目标模式** - 排障诊断只检查 config.toml / auth.json 文件自洽性,未考虑用户在 UI 上选择的 Codex 模式(快捷/插件),导致选了快捷模式但磁盘仍是插件模式时误报”配置一致”。修复后前端排障会比较 `status.mode`(磁盘实际模式)与 `codexMode`(UI 目标模式),不一致时优先报模式冲突并提示重新应用。涉及 `desktop/frontend/src/composables/useResponsesDiagnostics.ts`、`desktop/frontend/src/i18n/messages.ts`\n- **桌面端 Codex 排障按需触发** - Codex 排障诊断改为按需触发,避免不必要的自动诊断开销\n- **桌面端渠道介绍 i18n 对齐** - 补齐渠道介绍缺失的 key 与中文产品描述\n- **桌面端 Fuzzy 模式 API 路径补全** - Fuzzy 模式 API 路径自动补全 `/api` 前缀并改为 watch 驱动加载\n- **桌面端熔断器配置预设值同步** - 同步熔断器配置预设值、slider 范围与 WebUI 一致\n- **桌面端 fetch 网络层错误包装** - 包装 fetch 网络层错误,避免管理面板显示原始 `Failed to fetch` 信息\n- **桌面端切换渠道目标时清除成功提示条** - 修复切换渠道目标时残留上一目标操作结果的问题\n- **桌面端补齐 claudeProviderKeys 缺失键** - 补齐 claudeProviderKeys 缺失的 openrouter/modelscope 键,修复 TS2345 类型错误\n- **桌面端当前渠道显示补上渠道名称** - 修复当前渠道显示缺失渠道名称的问题\n- **桌面端渠道预设描述统一** - 以平台身份重写渠道预设描述,统一 MiniMax M3 旗舰描述文案\n- **桌面端 agent 配置绑定同步** - 同步 agent 配置绑定关系\n- **按 content block index 管理 tool_use 缓冲** - 修复重叠 tool_use 导致 `Content block not found` 错误\n- **preflight 对未知 SSE 事件类型增加内容兜底识别** - Responses preflight 阶段对未知 SSE 事件类型增加内容兜底识别逻辑\n- **识别 messages→responses 桥接下的上游错误** - 修复 messages→responses 桥接场景下上游错误/失败/reasoning 事件未被正确识别的问题\n- **调整熔断器预设超时值** - 均衡档改为 60s/60s/180s\n- **渠道级流式超时预设与全局调校台对齐** - 渠道级流式超时预设值与全局调校台保持一致\n- **补齐渠道调校台参数同步** - 修复渠道级调校台参数未与全局配置同步的问题\n- **优化 RunAPI 渠道预设与排序** - 优化 RunAPI 渠道预设配置与列表排序\n- **tune 工具调用空闲处理** - 调整流式工具调用空闲超时参数\n- **加固本地 compact prompt** - 加固 Responses compact 本地提示词\n\n### 重构\n\n- **合并历史图片轮次限制到熔断器对话框** - 将历史图片轮次限制配置合并到熔断器对话框中,减少工具栏按钮数量\n- **从 EnvTab 移除熔断器配置卡片** - 从 EnvTab 移除熔断器配置卡片,统一由管理面板弹窗管理\n- **熔断器设置更名为调校台 Tuning Bench** - 将熔断器设置相关 UI 文案统一更名为”调校台 Tuning Bench”\n\n### 文档\n\n- **上游评估 TODO 更新** - 添加上游评估待办事项,记录 #190 新特性条目与上游协议变更\n- **upstream-check TODO 格式对齐** - 对齐上游检查脚本 TODO 分组标题层级与任务格式\n\n### 其他\n\n- **将上游检查脚本迁入技能目录** - 将上游版本检查脚本从 tools/ 迁入技能目录,优化技能目录结构\n\n## [v2.8.25] - 2026-06-07\n\n### 新增\n\n- **RunAPI 赞助商与桌面端直连预设** - 新增 RunAPI 赞助商展示、渠道中心预设与 Claude/Codex/OpenCode 直连配置,支持 Messages、Chat、Responses 三类目标\n- **流式 post-commit 阶段畸形工具调用截断策略** - 文本已输出后遇到畸形 `tool_use` 时缓冲并截断工具调用,注入 `end_turn` 与 `message_stop` 正常结束,避免客户端进入无效 `tool_result` 重试\n- **Responses 添加 tool_search 工具类型识别与兼容处理** - 新增 tool_search 工具类型的识别与兼容处理逻辑\n- **新增 stripImageGenerationTool 渠道开关** - 渠道配置新增 stripImageGenerationTool 开关,支持过滤图片生成工具\n- **桌面端同步 stripImageGenerationTool 渠道开关** - 桌面端编辑渠道界面同步支持 stripImageGenerationTool 开关\n- **上游版本每日自动检查 skill 与脚本** - 添加上游版本每日自动检查 skill 与脚本\n- **渠道管理界面增强与国际化完善** - Web UI 渠道管理界面增强与国际化完善\n- **新增流式响应观察者模式** - 流式响应新增观察者模式,支持可插拔的流式处理逻辑\n- **上游请求日志接入请求上下文标签** - 为上游请求日志接入 session/round 请求上下文标签\n- **新增请求日志上下文模块** - 新增请求日志上下文模块支持 session/round 标签\n- **流式响应 TPS 进度记录** - 记录流式响应 TPS 进度\n- **流式 200 伪成功检测与 failover 机制** - 新增流式 200 伪成功检测与 failover 机制\n- **Desktop 前端熔断器配置新增流式超时滑块** - 桌面端熔断器配置界面新增流式超时滑块\n- **Web 前端熔断器配置新增流式超时滑块** - Web 前端熔断器配置界面新增流式超时滑块\n- **Gemini/Images 流式 preflight 适配** - 完成 Gemini/Images 流式 preflight 适配\n- **Gemini handler 初步接入流式 preflight 检测** - Gemini handler 初步接入流式 preflight 检测\n- **Responses handler 流式两阶段 preflight 检测** - Responses handler 接入流式两阶段 preflight 检测\n- **Chat handler 流式两阶段 preflight 检测** - Chat handler 接入流式两阶段 preflight 检测\n- **Minimax 2.7 思考标签处理升级** - 升级 Minimax 2.7 思考标签处理为协议级提取与原生推理字段转换\n\n### 修复\n\n- **记录缓冲工具事件的原始流日志** - 修复 tool_use 缓冲期间原始 SSE 事件未写入响应日志的问题,确保工具开始、参数增量与结束事件可追踪\n- **渠道中心预设顺序与 RunAPI Responses 文案优化** - 保持后端预设顺序避免按 Key 分组导致列表跳位,并为 RunAPI Responses 目标显示原生 Responses 方案文案\n- **桌面端切换渠道时清除成功提示与错误状态** - 修复切换渠道时旧的成功提示和错误状态残留的问题\n- **修复三个疑似 bug (#162 #187 #188)** - 修复三个疑似 bug\n- **延长流式空闲超时限制** - 延长 stream idle timeout limits\n- **保留流式 preflight 诊断信息** - 保留 stream preflight diagnostics\n- **修复 Windows 图标透明度并补全 Codex 预设模板** - 修复桌面端 Windows 图标透明度并补全 Codex 预设模板\n- **添加 guide/ 重定向文件修复 GitHub 链接 404** - 修复 docs/guide 重定向导致的 GitHub 链接 404\n- **将完整工具调用视为非空流** - 修复完整工具调用被误判为空流的问题\n- **修复 JSON 日志截断破坏 Unicode 字符** - 修复 JSON 日志截断时破坏 Unicode 字符的问题\n- **修复跨协议能力测试虚拟协议检测** - 修复跨协议能力测试中虚拟协议检测逻辑\n- **修正 MSIX 数据目录显示路径** - 修正桌面端 MSIX 数据目录显示路径\n- **流式工具调用使用 idle timeout** - 修复流式工具调用使用正确的 idle timeout\n- **分离工具调用流式超时** - 将工具调用流式超时与普通流式超时分离\n- **修复 compact 流式转换状态 panic** - 修复 Responses compact 流式转换状态 panic\n- **捕捉已提交流式响应断流** - 修复已提交流式响应断流未被捕捉的问题\n- **移除流式超时 off 档并为渠道新增超时覆盖开关** - 移除流式超时 off 档并为渠道新增超时覆盖开关\n- **修复工具调用流式 preflight 误判** - 修复 Chat 工具调用流式 preflight 误判\n- **修复 restoreApiKey 接口请求路径** - 修复桌面端 restoreApiKey 接口请求路径\n- **修复编辑渠道对话框拉黑密钥恢复按钮不可点击** - 修复桌面端编辑渠道对话框拉黑密钥恢复按钮不可点击\n- **修复编辑渠道对话框拉黑密钥恢复按钮不可点击 (Web)** - 修复 Web 端编辑渠道对话框拉黑密钥恢复按钮不可点击\n- **规范化 compact reasoning 输出** - 修复 Responses compact reasoning 输出格式\n- **修复频道菜单缺失 i18n** - 修复桌面端频道菜单缺失 i18n 翻译\n- **对齐管理面板协议显示名** - 修复桌面端管理面板协议显示名不一致\n- **修复 Minimax 2.7 模型 think 标签问题** - 修复 minimax 2.7 模型有 `` 标签的问题\n\n### 重构\n\n- **按语言拆分 i18n 消息文件** - 前端 i18n 消息文件按语言拆分\n- **拆分 API 服务层类型和工具** - 前端 API 服务层类型和工具拆分\n- **移除 Wails v3 系统通知服务** - 桌面端移除 Wails v3 系统通知服务\n\n### 测试\n\n- **补充 RunAPI 桌面端预设与 Agent 配置回归测试** - 覆盖 RunAPI Messages/Chat/Responses 渠道 payload、官网链接、Claude 状态识别与 Key 复用、Codex 快捷/插件模式、OpenCode 直连配置\n- **GPT 类渠道探测模型覆盖 codex-auto-review** - GPT 类渠道探测模型新增 codex-auto-review 覆盖\n- **覆盖无模型映射的协议转换测试组** - 新增无模型映射的协议转换测试组覆盖\n- **拆分 chat_to_responses 测试文件** - 拆分 converters chat_to_responses 测试文件\n- **拆分 configservice 测试文件** - 拆分桌面端 configservice 测试文件\n- **拆分 failover 测试文件** - 拆分 common failover 测试文件\n\n### 文档\n\n- **更新 TODO 格式规范与疑似 bug 修复记录** - 更新 TODO 格式规范与疑似 bug 修复记录\n- **更新 TODO 完成状态与关键提交记录** - 更新 TODO 完成状态与关键提交记录\n- **添加 GPT 类渠道模型测试覆盖待办** - 添加 GPT 类渠道模型测试覆盖待办\n- **新增桌面端渠道成功提示清理待办** - 新增桌面端渠道成功提示清理待办\n- **精简历史版本记录** - 精简 CHANGELOG 历史版本记录\n\n### 其他\n\n- **新增 Serena MCP 配置与项目记忆** - 新增 Serena MCP 配置与项目记忆\n- **升级 Vite 和 Vitest** - 前端升级 Vite 和 Vitest 依赖\n- **合并 guide/ 重定向文件修复** - 合并 guide/ 重定向文件修复分支\n\n## [v2.8.24] - 2026-06-04\n\n### 修复\n\n- **修复深层嵌套图片检测遗漏导致视觉回退失效** - 修复嵌套结构中图片 URL 未被识别,导致视觉回退策略无法生效的问题\n\n## [v2.8.23] - 2026-06-04\n\n## [v2.8.22] - 2026-06-04\n\n### 修复\n\n- **修复桌面端管理面板 CORS 认证失败** - 修复 `OPTIONS` 预检请求被认证中间件拦截返回 401,导致桌面端 WebView 无法访问管理接口的问题;`ENABLE_CORS=false` 时仍为带 `Origin` 的请求注入 CORS 响应头\n- **version-bump 新增 CHANGELOG 覆盖率校验** - 版本升级流程新增 CHANGELOG 覆盖率校验,防止遗漏提交记录\n\n### 文档\n\n- **补全 v2.8.21 CHANGELOG** - 补全 v2.8.21 版本遗漏的 CHANGELOG 条目\n- **新增 Windows 桌面图标透明度待办项** - 新增 Windows 桌面图标透明度相关的待办任务\n\n## [v2.8.21] - 2026-06-04\n\n### 新增\n\n- **桌面端原生管理控制台** - 桌面端原生管理控制台替代 iframe WebUI,完整覆盖 i18n,管理入口统一命名为 Dashboard\n- **桌面端渠道管理重构** - 添加渠道简化为快速粘贴模式(支持 Cmd/Ctrl+Enter 创建),编辑渠道弹窗对齐 WebUI 核心交互,新增预设模板、模型拉取、高级选项分组、Base URL 预览、服务类型动态排序、模型映射协议预置列表与 filter chips、API Key 必填校验\n- **桌面端渠道操作增强** - 渠道卡片支持置顶置底并增加删除守卫,渠道日志弹窗重构为可展开列表并支持自动刷新,删除渠道时显示 loading 遮罩,模型重定向 target 输入框始终提供候选\n- **桌面端会话驾驶舱重构** - 会话驾驶舱重构为瀑布流卡片并对齐 WebUI cockpit,能力测试对话框对齐 WebUI,能力测试多 job 并发轮询修复\n- **桌面端 Codex 会话迁移** - 添加 Codex 会话迁移支持,快捷/插件模式切换对齐 WebUI\n- **CLI --backupdir 选项** - 新增 `--backupdir` 选项指定配置备份目录\n- **CLI 自定义运行时路径** - 支持自定义运行时路径\n- **LOG_DIR=none 禁用日志文件** - 支持 `LOG_DIR=none` 环境变量禁用日志文件写入\n- **渠道级请求超时配置** - 后端渠道配置新增 `requestTimeoutMs`,非流式上游请求优先使用渠道超时,未设置或为 0 时继承全局 `REQUEST_TIMEOUT`\n\n### 修复\n\n- **messages**: system 角色归一化对所有上游生效\n- **日志 store 改用 metricsKey 分桶** - 根治删除渠道后日志串桶问题\n- **模型过滤拒绝非法字符** - 模型过滤拒绝顿号等非法字符并按分隔符拆分\n- **余额不足/套餐过期自动拉黑** - 补全 403 关键词自动拉黑,改为双词组合匹配覆盖更多上游变体\n- **下架已停用 GPT 模型** - 从能力测试候选、模型重定向候选和预设中移除 `gpt-5.2` / `gpt-5.2-codex` / `gpt-5.3-codex`,haiku 映射改为 `gpt-5.4-mini`\n- **桌面端编辑渠道大量 UI 修复** - 快捷键与底部操作栏、Esc/Enter 快捷键、窄窗口布局、高级选项平铺 Runtime/Transport 分组、Website 移入连接分组、模型卡片布局、noVisionModels 合并行级 toggle、思考强度/Text verbosity 下拉修复、popover 主题变量补全、移除冗余 Latency 列与重复快捷键提示、保存快捷键统一为 Cmd/Ctrl+Enter\n- **桌面端快速粘贴优化** - Textarea 高度修正、自动应用识别结果、自动补全 name/serviceType、按钮提示显示平台对应修饰符、移除 Service Type 识别卡片\n- **桌面端 Dashboard 修复** - 移除轮询可见性检查修复渠道列表不自动刷新、管理面板 Tabs 配色对齐深色主题、备用池文案对齐 WebUI、驾驶舱命名对齐并移除手动刷新、网关监控按钮改为「打开管理面板」\n- **桌面端模型列表修复** - 拉取补传 key 参数修复 'No API key provided' 错误、源模型列表对齐 WebUI allSourceModelOptions、预设数据与按钮分组完全对齐 WebUI\n- **桌面端移除一键配置已下线模板** - 移除 gpt-5.3/gpt-5.2 模板,haiku 改映射到 gpt-5.4-mini\n- **桌面端渠道中心恢复原有 ChannelTab 界面** - 移除渠道测速按钮,补齐编辑弹窗快捷键\n- **桌面端 i18n 补全** - 编辑渠道界面、编辑弹窗新增 i18n keys 等缺失翻译补全\n\n### 重构\n\n- **桌面端管理入口命名统一** - 管理入口统一命名为 Dashboard,隐藏批量测速按钮,移除 Codex 分组冗余开关\n- **桌面端编辑渠道布局对齐** - 请求超时从基础信息移入 Runtime 分组,抽取渠道草稿保存逻辑统一提交路径\n- **渠道管理清理** - 移除未使用代码(开启 noUnusedLocals)、删除渠道编辑弹窗冗余元素\n\n### 文档\n\n- **Docker 部署示例修正** - 修正 Docker 部署示例与实际 compose 保持一致\n- **LOG_DIR=none 补充文档** - 补充 `LOG_DIR=none` 禁用日志文件文档及测试用例\n- **新增 TODO 待办任务文档**\n\n## [v2.8.20] - 2026-06-01\n\n### 新增\n\n- **渠道级请求超时配置** - 后端渠道配置新增 `requestTimeoutMs`,非流式上游请求优先使用渠道超时,未设置或为 0 时继承全局 `REQUEST_TIMEOUT`;五类渠道新增/更新路径均校验负数并支持清空回退。\n- **Web UI 渠道请求超时表单** - 渠道编辑表单新增“请求超时 (ms)”输入项,支持编辑态清空恢复继承全局,并在复制渠道、payload 序列化、差异检测和多语言文案中同步该字段。\n\n### 新增\n\n- **桌面端 Codex Agent 配置双模式** - Codex 支持两种 CCX 模式:快捷模式(OpenAI provider + CCX 代理,不迁移会话、不支持插件)和插件模式(CCX 原生 provider + `requires_openai_auth = true`,支持插件但切换需会话迁移提示)。UI 下拉框拆分为两个选项,互切时显示会话迁移警告。\n- **Codex 配置改用快捷/插件模式开关** - 桌面端 Codex 配置入口统一改为快捷模式/插件模式开关,明确不同接入路径。\n- **Codex provider 块统一使用 `env_key`** - 将 `[model_providers.xxx]` 块中的 `temp_env_key` 统一替换为官方文档字段 `env_key`。\n- **桌面端 AgentCard 编辑器打开支持多编辑器选择** - 配置文件和认证文件的\"用编辑器打开\"按钮现在支持多编辑器场景,通过透明 select 覆盖层提供编辑器选择下拉。\n- **渠道中心已保存 key 的 preset 优先排列** - 桌面端渠道中心将已保存 API Key 的 preset 优先展示,便于快速选择常用渠道。\n- **已配置 key 的渠道在渠道中心优先展示** - Web 前端渠道中心根据已配置 Key 状态优先排列渠道。\n- **Codex 渠道模板新增 codex-auto-review 重定向** - 渠道模板新增 codex-auto-review 重定向能力。\n- **新增 WebUI 新用户指引** - WebUI 增加新用户引导说明,帮助首次使用者快速理解渠道配置流程。\n- **OpenAI 直连增加 API Key 勾选框** - 桌面端 OpenAI 直连新增「我有自己的 API Key」勾选框。\n- **新用户指引添加渠道展示模拟表单** - 新用户指引增加渠道展示模拟表单,更直观展示快速模式字段。\n- **Codex 第三方 provider 支持快捷/插件模式切换** - 第三方 Codex provider 支持在快捷模式与插件模式之间切换。\n\n### 修复\n\n- **下架已停用 GPT 模型** - 从能力测试候选、模型重定向候选和预设中移除 `gpt-5.2` / `gpt-5.2-codex` / `gpt-5.3-codex`,haiku 层模型统一替换为 `gpt-5.4-mini`,并为桌面端渠道模板补充旧模型回归检查。\n\n- **浏览器直达移除 `ccx_desktop` 参数** - 修复浏览器直达 WebUI 时残留桌面端参数的问题。\n- **默认语言改为中文并增加浏览器语言检测** - 修复默认语言选择逻辑,新增浏览器语言检测。\n- **新用户指引协议切换说明拆分为三段** - 调整新用户指引中协议切换说明的展示结构。\n- **新用户指引简化标签列表** - 将新用户指引标签列表简化为 Claude / Codex / Gemini 等。\n- **新用户指引示例渠道改为 gpt-5.5 中转** - 更新新用户指引中的示例渠道文案。\n- **新用户指引删除 quickHint** - 删除 quickHint,改为说明 CCX 自动识别。\n- **删除新用户指引模拟表单及关联资源** - 删除模拟表单及其关联 i18n/CSS 资源,简化引导内容。\n- **新用户指引添加渠道说明拆分为两段** - 调整添加渠道说明,使内容层次更清晰。\n- **渠道置顶后保持渠道顺序** - 修复渠道置顶后顺序被打乱的问题。\n- **修正 Codex 认证配置模式** - 修复 Codex 认证配置模式写入与展示不一致的问题。\n- **渠道排序以 priority 优先,hasKey 仅作兜底** - 修复渠道排序中 hasKey 权重过高导致 priority 失效的问题。\n- **消除 Codex 非 OpenAI provider 时悬空分隔符** - 修复 Codex 非 OpenAI provider 表单中多余竖线分隔符。\n- **调整 Linux 数据目录到 XDG state** - 修复 Linux 桌面端数据目录位置,改用 XDG state 路径。\n- **掩码 Codex 配置预览密钥** - 修复 Codex 配置预览中密钥未正确脱敏的问题。\n- **修正 Codex `auth_mode` 为 `apikey`** - 修正 Codex 配置中的认证模式字段值。\n- **OpenAI 直连取消勾选 API Key 时清空残留值** - 修复 OpenAI 直连关闭自带 Key 后仍保留旧值的问题。\n- **修正 Codex 配置预览 `auth_mode` 被误掩码** - 修复配置预览中普通字段被误判为密钥的问题。\n- **对齐 Codex 插件模式认证配置** - 修复 Codex 插件模式下认证配置与预期不一致的问题。\n- **JSON 配置 diff 改为字段级掩码** - 修复 JSON 配置 diff 脱敏粒度过粗导致预览不准确的问题。\n- **修正短密钥掩码后 JSON diff 漏报变更** - 修复短密钥脱敏后 JSON diff 无法识别真实变更的问题。\n- **按字段掩码 Codex 配置 diff** - Codex 配置 diff 改为按字段掩码,避免误掩码普通配置项。\n- **OpenAI 直连自带 Key 必须输入** - 修复 OpenAI 直连勾选自带 Key 时允许空值的问题。\n\n### 重构\n\n- **移除左上角 Logo 点击跳转 GitHub 链接** - 移除 UI 左上角 Logo 的 GitHub 跳转行为。\n- **配置预览文件标签改用增删行数统计** - 桌面端配置预览文件标签改为展示增删行数统计。\n\n### 文档\n\n- **更新桌面安装文档** - 更新桌面安装文档,补充 Windows Store 已上架和 Homebrew 安装说明。\n- **排障指南添加各入口 curl 快速验证命令** - 为排障指南补充各协议入口的 curl 快速验证命令。\n- **排障指南首条添加关闭全局代理和 TUN** - 排障指南首条新增关闭全局代理和 TUN 的排查建议。\n\n### 其他\n\n- **修复 go fmt 格式化缩进** - 修正 Go 格式化后的缩进样式。\n- **移除 Claude-Reasoning-Debug 调试日志** - 移除 Claude-Reasoning-Debug 调试日志及 summarizeAssistantReasoningState 函数。\n- **精简新用户指引内容** - 移除新用户指引中的状态图例、开关和接入客户端三节。\n- **补充 desktop type-check 脚本** - 为桌面前端补充 type-check 脚本。\n- **固化 Wails3 CLI 安装版本** - CI 中固定 Wails3 CLI 安装版本,提升构建稳定性。\n\n## [v2.8.19] - 2026-05-30\n\n### 修复\n\n- **补全 MiMo target 描述删除遗漏** - 补全桌面端 MiMo target 描述删除时的遗漏处理。\n\n## [v2.8.18] - 2026-05-30\n\n### 新增\n\n- **按 ReasoningParamStyle 转换客户端原始 reasoning 参数** - 新增按 ReasoningParamStyle 配置项转换客户端原始 reasoning 参数的能力。\n- **Passthrough 路径 ReasoningMapping 分支补充 reasoning_effort 样式处理** - 在 passthrough 路径的 ReasoningMapping 分支中补充 reasoning_effort 样式的处理逻辑。\n- **桌面端 add Homebrew tap 自动化** - 新增 Homebrew tap 自动化发布流程。\n- **桌面端点击版本号触发手动更新检查并计入冷却时间** - 点击版本号可手动触发更新检查,并计入冷却时间避免频繁检查。\n- **桌面端 Codex CCX 代理切换为 openai_base_url 官方模式** - Codex CCX 代理从自定义模式切换为 openai_base_url 官方模式。\n\n### 修复\n\n- **渠道中心布局断点降低至 md 并修复窄屏 URL 溢出** - 修复渠道中心在窄屏下的布局问题,将断点降低至 md 并修复 URL 文本溢出。\n- **Responses API 返回 426 让 Codex WebSocket 回退到 HTTP POST** - 修复 Responses API 返回 426 状态码时 Codex WebSocket 正确回退到 HTTP POST。\n- **修复断裂文档链接,统一 PORT=3688,补充源码构建前置依赖** - 修复文档中断裂的链接,统一 PORT 配置为 3688,补充源码构建的前置依赖说明。\n- **MiMo messages 目标描述统一为通用表述** - 统一桌面端 MiMo messages 渠道目标描述为通用表述。\n\n### 重构\n\n- **移除熔断器弹窗标题图标** - 移除熔断器弹窗中的标题图标,简化界面。\n- **渠道中心添加目标标签改为产品名称** - 将渠道中心的添加目标标签改为显示产品名称。\n\n### 其他\n\n- **UI 熔断器配置改为预设按钮+滑块交互** - 将熔断器配置改为预设按钮+滑块的交互方式,删除重复的 .env 分组。\n- **UI 熔断器参数三列并排布局+间隔线** - 调整熔断器参数为三列并排布局并增加间隔线。\n- **UI 预设按钮移到滑块下方,参数列增加内边距** - 将预设按钮移到滑块下方,参数列增加内边距优化间距。\n\n## [v2.8.17] - 2026-05-29\n\n### 新增\n\n- **新增 gpt-5.4-mini 到 codex responses 协议源模型列表** - 前端 codex responses 协议源模型列表新增 gpt-5.4-mini 模型。\n- **更新 Gemini 源模型列表** - 更新前端 Gemini 源模型列表配置。\n- **更新模型列表并同步前后端** - 更新模型列表并保持前后端配置同步。\n\n### 修复\n\n- **WebUI iframe 认证使用 ADMIN_ACCESS_KEY 而非 PROXY_ACCESS_KEY** - 修复桌面端 WebUI iframe 认证使用错误密钥的问题。\n\n### 其他\n\n- **简化 favicon,替换为极简 CX 描边图标** - 简化 favicon 设计,替换为极简 CX 描边图标。\n\n## [v2.8.16] - 2026-05-29\n\n### 新增\n\n- **添加 DashScope Token Plan 预设** - 桌面端添加 DashScope Token Plan 预设。\n\n### 修复\n\n- **修正渠道一键预设显示** - 修正 UI 渠道一键预设显示问题。\n- **复用 Windows 安装器旧安装目录** - 桌面端 Windows 安装器复用旧安装目录。\n- **修正 Web Logo 主题配色** - 修正 Web Logo 在不同主题下的配色显示。\n- **修正暗色模式 Logo 配色** - 修正桌面端暗色模式下 Logo 配色。\n- **修正图标亮暗主题显示** - 修正 UI 图标在亮暗主题下的显示。\n- **隐藏桌面 WebUI 版本检测** - 隐藏桌面端内嵌 WebUI 版本检测。\n\n### 其他\n\n- **隔离 backend 端口选择测试避免读取真实 .env** - 隔离桌面端 backend 端口选择测试,避免读取真实 `.env`。\n\n## [v2.8.15] - 2026-05-29\n\n### 新增\n\n- **重设计应用图标与品牌 Logo** - 桌面端应用图标与品牌 Logo 全新设计。\n\n### 修复\n\n- **统一 BuildChannelView 高级开关字段输出** - 将 `normalizeSystemRoleToTopLevel`、`stripEmptyTextBlocks`、`passbackReasoningContent`、`passbackThinkingBlocks`、`injectDummyThoughtSignature`、`stripThoughtSignature` 六个高级开关字段统一收归 `BuildChannelView()` 公共函数输出,消除 messages/chat/responses/gemini 四个 handler 中的重复补丁,确保所有渠道类型的 WebUI 编辑回显与保存一致。\n- **移除 PreviewAgentConfigDiff 未使用的 status 变量** - 清理桌面端 PreviewAgentConfigDiff 组件中未使用的 status 变量。\n- **Agent 配置应用使用最新 .env 端口** - 桌面端 Agent 配置应用时使用最新的 .env 端口值。\n- **统一 Web UI Logo 与应用图标** - 统一 Web UI 中的 Logo 和应用图标,保持视觉一致性。\n- **补齐渠道一键模板配置** - 前端补齐渠道一键模板配置功能。\n- **调整 Opus 4.8 能力探测请求** - 调整 Claude Opus 4.8 模型的能力探测请求参数。\n- **修复预设渠道控制台网址和套餐显示** - 修复预设渠道中控制台网址和套餐信息显示不正确的问题。\n\n## [v2.8.14] - 2026-05-29\n\n### 新增\n\n- **新增 normalizeSystemRoleToTopLevel 开关兼容旧 Claude 上游** - 新增系统角色规范化到顶层字段的兼容开关,便于旧版 Claude 上游适配。\n- **统一桌面端与 Web UI 的 MiMo/DeepSeek 渠道预设配置** - 统一桌面端与 Web UI 的 MiMo、DeepSeek 渠道预设,保持跨入口配置一致。\n- **补全渠道中心全部预设的中英文 i18n** - 为渠道中心全部预设补齐中英文国际化文案。\n- **主题切换改为自动/亮色/暗色三按钮,默认跟随系统** - 桌面端主题切换调整为自动、亮色、暗色三按钮,并默认跟随系统主题。\n\n### 修复\n\n- **修复 Messages 嵌套工具结果图片未触发视觉回退** - 识别 `tool_result.content` 等嵌套 content 数组中的图片块,确保 MiMo 等配置 `visionFallbackModel` 的渠道在历史截图请求中正确切换到识图回退模型,并跳过 `noVision` 渠道。\n- **调整主题选项排序** - 调整桌面端主题选项展示顺序,使默认与常用选项更符合使用习惯。\n- **修复中文模式下渠道说明显示英文** - 修复中文界面中渠道说明仍显示英文的问题。\n- **修复渠道 index 为 0 时能力测试无法启动** - 修复前端能力测试在渠道 index 为 0 时被误判而无法启动的问题。\n- **修复桌面端内嵌 Web UI 能力测试无响应的问题** - 修复桌面端内嵌 Web UI 发起能力测试后无响应的问题。\n- **修复亮色模式下所有状态色文字对比度不足** - 调整亮色模式状态色文本样式,提升可读性。\n\n### 重构\n\n- **移除浏览器模式的 Wails mock 传输层** - 移除桌面端浏览器模式下的 Wails mock 传输层,简化开发模式通信链路。\n\n## [v2.8.13] - 2026-05-29\n\n### 新增\n\n- **添加 claude-opus-4-8 模型到能力探测与优先级列表** - 前端能力探测与模型优先级排序规则新增 claude-opus-4-8 模型支持。\n- **桌面端亮暗色主题切换** - 桌面端新增亮色/暗色主题切换功能,支持用户自定义界面外观。\n\n### 其他\n\n- **桌面端 golang.org/x/sys 提升为直接依赖** - 将 `golang.org/x/sys` 从间接依赖提升为直接依赖,确保依赖关系清晰。\n\n## [v2.8.12] - 2026-05-28\n\n### 修复\n\n- **Linux 桌面构建依赖与运行环境更新** - 更新 CI 中 Linux 桌面构建的系统依赖为 gtk4 与 webkitgtk-6.0,构建运行环境切换至 ubuntu-24.04,确保桌面端在最新 Linux 发行版上正常编译与运行。\n\n## [v2.8.11] - 2026-05-28\n\n### 新增\n\n- **桌面端浏览器直连 Vite dev 端口可走通初始化** - 开发模式下支持浏览器直接连接 Vite dev 端口完成初始化流程(mock Wails IPC),无需通过 Wails 桌面窗口即可调试前端。\n\n### 修复\n\n- **WebUI 快速添加渠道不再误解析配置键** - 修正输入解析,避免 `api_key:`、`url:` 等字段名被识别为 API Key;解析逻辑改为保护 URL 中的冒号,并增加常见配置键名白名单过滤。\n- **WebUI 快速添加渠道后新渠道始终置顶** - 修正快速添加流程中渠道定位方式,不再依赖 \"index 最大\" 的启发式;改为按渠道名称精确匹配新渠道,确保新渠道保持在第一位并触发促销期。\n- **Windows 首次启动语言检测按平台分支** - 桌面端分平台检测系统语言,修复 Windows 首次启动时语言显示为英文的问题。\n- **Linux AppImage 使用 Ubuntu 22.04 构建以兼容 GLIBC 2.35** - CI 构建环境从新版 Ubuntu 降级到 22.04,确保 AppImage 在较旧 Linux 发行版上正常运行。\n\n### 文档\n\n- **推荐下载卡片增加 gh-proxy 加速下载链接** - 为下载卡片添加 gh-proxy 加速镜像链接,提升国内用户下载体验。\n- **站点 hero 区域单行标题与 tagline 扩展** - hero 区域改为单行标题、tagline 扩展上游列表与 env 代码块高亮。\n\n## [v2.8.10] - 2026-05-28\n\n### 新增\n\n- **桌面端多语言切换** - 新增轻量 i18n 框架(zh-CN / en),默认英文并自动跟随系统语言,首次启动按系统语言选择、手动切换后持久化到 `ui-preferences.json`;DesktopService 暴露 `GetLanguagePreference` / `SaveLanguagePreference`;侧栏底部守护面板新增语言切换入口;Sidebar、Setup、Status(含 LogViewer、DiagnosticCard)、Agent(含 ProviderForm、ConfigDiffDialog)、Channel、Env、WebUI 等用户可见文案全部迁移到 `t()`,便于 Microsoft Store Windows 截图审核。\n- **桌面端 Agent 配置支持 OpenCode** - configservice 新增 `PlatformOpenCode` 平台、`OpenCodeProxyState`、JSONC provider 块解析与 `auth.json` 读写;默认 ccx provider 走 `http://127.0.0.1:/v1`,可选直连 DeepSeek/MiMo/Compshare/Kimi/GLM/MiniMax/DashScope/OpenCode Zen/Go;写入 `~/.config/opencode/opencode.jsonc` 与 `~/.local/share/opencode/auth.json`;前端 AgentTab/AgentCard/ConfigDiffDialog 渲染 OpenCode 卡片;同步补充中英文 OpenCode 客户端 Desktop 自动配置文档。\n- **静默版本检查替代 wails updater 自动弹窗** - 关闭 wails updater 30 分钟自动 `CheckAndInstall`(无差别弹窗,失败场景尤为干扰);新增 `desktopservice.CheckLatestRelease(force)` 直查 GitHub Releases,过滤 prerelease,服务端缓存 4 小时(错误 30 分钟);侧栏版本号旁追加「新版 vX.X.X」胶囊,仅有更新时显示,点击打开 release 页(Store 分发不显示);抽出 `useReleaseCheck` composable(启动 8s 首次检查、之后 4 小时一次);托盘菜单「检查更新…」入口保留,主动点击仍走完整 wails updater 安装流程。\n- **OpenCode 直连仅保留官方原生支持的国产渠道** - OpenCode 卡片下拉移除 MiMo/Compshare/DashScope(OpenCode 官方未原生支持),保留 ccx + DeepSeek/Kimi/GLM/MiniMax + Zen/Go;`useAgentConfig` 同步补齐对应 baseURL 分支。\n- **渠道中心 preset 文案 i18n 化并区分 plan 渠道名** - 新增 `translateOrFallback` / `tf` helper,preset/plan/target 文案按 key 翻译、缺失 key 回退到 Go preset 原文;新增 `preset-messages.ts` 仅维护 EN,zh-CN 复用后端中文;channelName 默认值在选中非默认 plan 时追加 plan suffix,避免 MiMo/DashScope 多套餐同名覆盖。\n\n### 修复\n\n- **/v1 等裸 API 前缀不再被 SPA 兜底成 WebUI** - `isAPIPath` 仅匹配带尾部斜杠的前缀(\"/v1/\" 等),导致 `GET /v1`、`GET /api`、`GET /admin` 这类裸前缀落到 NoRoute 的 SPA 兜底分支返回 `index.html`;改为同时匹配 `path == prefix` 与 `prefix+\"/\"`,并补上之前漏掉的 `/v1beta`(Gemini 原生格式);新增表驱动测试覆盖裸前缀、子路径以及 `/v1custom` / `/apifoo` 等非 API 路径。\n- **状态页 ServiceDetails 去除与 Sidebar 重叠的 PID/Health 信息** - 移除卡片内 PID 与健康状态行(Sidebar 已展示),同时去掉冗余小标题,避免与左侧栏视觉重叠。\n- **统一 Agent 卡片 Provider 下拉文案至 i18n** - Codex 卡片 ccx 选项原使用 `agent.localGateway`(\"当前 CCX 网关\"),与 Claude/OpenCode 卡片的 `agent.provider.localGateway`(\"CCX 本地网关\")不一致;OpenCode 卡片其余选项及\"访问官方控制台\"按钮、API Key placeholder 也是硬编码中文;统一改用 `agent.provider.*` 与 `agent.openConsole` i18n key。\n- **注册 mdi-head-snowflake 图标** - AddChannelModal 的\"回传 Thinking Blocks\"开关使用 `mdi-head-snowflake`,但 `vuetify.ts` 未导入,运行时图标回退到占位文本;补 `@mdi/js` 导入与 `iconMap` 映射。\n\n### 优化\n\n- **收紧 ServiceDetails 卡片上下内边距** - 桌面端状态页 ServiceDetails 卡片上下内边距收紧,提升信息密度。\n- **语言切换合入侧栏底部守护面板** - 桌面端语言切换入口整合到侧栏底部守护面板,与现有偏好控件统一布局。\n\n### 文档\n\n- **新增桌面端教程截图** - 为桌面端使用指南补充压缩后的 CCX Desktop 截图,并将端口引用对齐到默认 3688。\n\n## [v2.8.9] - 2026-05-27\n\n### 修复\n\n- **MiMo 计费目标 URL 不更新** - 修复桌面端 MiMo 计费配置切换后目标 URL 未正确更新的问题。\n- **Claude 渠道思考块透传缺失** - 修复 Claude 渠道返回 reasoning/thinking blocks 时未按配置回传的问题。\n- **Responses 透传渠道预设误展示 gpt-5.x 一键设置** - Responses 协议透传渠道隐藏不适用的 gpt-5.x 一键预设,避免误配置。\n- **导引完成后默认页签错误** - 桌面端首次导引完成后默认跳转 Agent 配置,而非渠道中心。\n- **Sidebar 导入路径错误** - 修复桌面端 `Sidebar.vue` 绑定导入路径使用 `@/bindings` 导致的问题,改为正确的 `@bindings`。\n- **Responses 到 Claude 缺少 max_tokens 默认值** - 修复 Responses → Claude 链路缺少 `max_tokens` 默认值的问题。(#133)\n\n### 其他\n\n- **补充 max_tokens 边界测试** - 为 Responses → Claude 转换链路补充 `max_tokens` 边界测试,覆盖默认值与异常边界。\n\n## [v2.8.8] - 2026-05-27\n\n### 新增\n\n- **DashScope Coding Plan 选项** - 桌面端 DashScope provider 新增 Coding Plan 计费选项,Claude 直连 Agent 配置支持 Coding Plan 选择器。\n\n### 变更\n\n- **桌面端自更新从手写 updater 改为 Wails v3 内置 Updater** - 删除 `desktop/internal/updater/` 包(含 macOS DMG 挂载、Windows NSIS 安装器、Linux AppImage nohup 替换等平台安装器,行为模式易被杀软误杀);移除 `CheckUpdate`/`DownloadAndInstall`/`CancelUpdate` Go 导出方法和 `UpdateInfo` 结构体;改用 Wails v3 `pkg/updater`(GitHub Releases Provider + SHA256SUMS 校验 + 内置更新 UI 窗口 + 30 分钟定时检查);删除前端 `useUpdater.ts` composable 和 `UpdateDialog.vue` 组件,Sidebar 版本按钮改为纯展示(更新检查通过托盘菜单触发);Wails v3 依赖从 alpha.95 升级到 alpha.96\n- **DashScope 按量付费计划标签对齐 MiMo 规范** - 统一 DashScope 渠道预设的按量付费计划标签显示,与 MiMo 规范保持一致。\n\n### 修复\n\n- **Chat 渠道选择 Responses 上游时请求格式与端点错误** - 修复 Chat 渠道 `serviceType: \"responses\"` 时仍以 Chat 格式发送到 `/v1/chat/completions` 的问题;新增 Chat↔Responses 双向协议转换(请求:messages→input, max_tokens→max_output_tokens;响应:output→choices),流式 SSE 事件转换(response.output_text.delta→Chat chunk),前端预期请求 URL 同步修正为 `/v1/responses`。(#130)\n- **规范化 role 开关提示不够明确** - 明确 i18n 中规范化 role 开关的提示文案,注明 developer 角色转换与国内模型建议。\n\n### 文档\n\n- **添加英文 CCX Desktop 指南和入口链接** - 新增英文版 CCX 桌面端使用指南与文档入口链接。\n\n## [v2.8.7] - 2026-05-27\n\n### 文档\n\n- **完善桌面端用户教程与客户端接入入口** - 补充桌面端使用教程与客户端接入文档入口说明。\n\n### 重构\n\n- **MiMo 渠道预设移除自定义 base URL,优化 token plan 切换与下拉标签** - MiMo 渠道预设不再设置自定义 base URL,同时优化 token plan 切换逻辑与下拉标签显示。\n- **go fmt 格式化 channelpreset 缩进对齐** - 对桌面端 channelpreset 模块执行 go fmt 统一格式化。\n\n### 修复\n\n- **含图请求不再覆盖普通文本 Trace 亲和** - 含图请求 failover 到视觉渠道成功后,不再改写同一用户的普通文本 Trace 亲和,避免 DeepSeek 等文本渠道被长时间绕过;`HasImageContentCached` 只读缓存,不影响已有 Images 渠道行为。\n- **恢复 BuildPayload Description 写入,移除 DMG 打包错误静默** - 恢复桌面端 BuildPayload 中 Description 字段的写入,移除 DMG 打包流程中对错误的静默处理以便排查问题,同时格式化相关测试文件。\n\n### 其他\n\n- **补充 NoVisionModels 断言覆盖** - 为桌面端 NoVisionModels 相关逻辑补充断言覆盖,增强测试完整性。\n\n## [v2.8.6] - 2026-05-26\n\n### 新增\n\n- **Images 渠道内置模型列表新增 gpt-image-2** - 前端 Images 渠道的内置模型选项新增 `gpt-image-2`。\n- **渠道中心 provider 官方控制台链接** - 桌面端渠道中心为各 provider 添加官方控制台访问链接,方便快速跳转管理;badges 与链接布局调整至标题同行,避免错位。\n- **macOS DMG 安装包自定义拖拽背景** - 新增自定义 DMG 背景图与安装布局,Applications 快捷方式与 .app 拖拽区对齐 FlyShadow 风格。\n\n### 变更\n\n- **Compshare 渠道预设调整** - 高级模型重定向(opus/sonnet/gpt-5.4)从 deepseek-v4-pro 改为 glm-5.1;Chat/Responses 渠道移除内置模型重定向(用户自行在客户端指定模型);视觉策略从全渠道 NoVision 改为仅 deepseek-v4-flash 关闭视觉,并设置 MiniMax-M2.7 为视觉回退模型。\n\n### 修复\n\n- **添加渠道不写入描述,新渠道插入列表首位** - 修复添加渠道时描述字段未写入的问题,新增渠道现在默认插入列表首位而非末尾。\n- **渠道预设视觉与重定向策略重构** - 统一桌面端渠道预设的视觉(NoVision)与模型重定向配置,修正多个预设中不一致的策略。\n\n## [v2.8.5] - 2026-05-26\n\n### 新增\n\n- **桌面端 Store MSIX 分发支持** - Windows 发布流程新增 Store MSIX 分发配置与打包脚本,为 Microsoft Store 分发补齐 manifest、package 脚本和 release 产物链路。\n- **桌面端 Compshare 推广入口** - 桌面端新增 Compshare 推广入口与外链打开能力,便于用户从侧边栏访问相关服务。\n- **Compshare Agent 直连支持** - 桌面端 Agent 配置新增 Compshare provider 选项,并同步支持直连配置状态识别与应用。\n- **Compshare 渠道预设** - 渠道中心新增 Compshare 预设,补充对应模型映射、服务类型和预设测试覆盖。\n\n### 文档\n\n- **隐私政策说明** - 新增中英文隐私政策页面,并将其接入文档导航。\n- **首页下载推荐展示优化** - 调整 README 首页下载推荐展示,并补充 VitePress 自适应下载推荐组件。\n- **首页自适应下载推荐** - 文档首页新增根据平台自适应展示推荐下载项的能力。\n\n### 其他\n\n- **合并 Compshare 桌面渠道支持** - 合并 Compshare 桌面渠道相关功能分支,纳入本次发布范围。\n\n## [v2.8.4] - 2026-05-25\n\n### 修复\n\n- **桌面端自动更新前停止后端进程** - 自动更新下载并校验安装包后,会先停止由桌面壳托管的 Go 后端,再启动平台安装器;如果当前后端由外部进程托管或停止失败,则中止更新并提示用户手动停止后重试,避免 Windows 安装时后端二进制被占用导致只更新 UI。\n\n## [v2.8.3] - 2026-05-25\n\n### 修复\n\n- **桌面端重启 CCX 服务后误报启动失败** - 网关监控重启服务时,停止与启动阶段改用独立超时上下文,避免停止耗时挤占启动健康检查时间;同时健康检查成功后清理过期错误状态,避免日志显示已重启成功但状态卡片仍展示启动失败。\n- **静默 health 轮询访问日志** - health 检查轮询现在遵循静默轮询日志配置,避免高频健康检查污染访问日志。\n- **桌面端窗口拖动时背景闪烁** - 桌面端窗口背板改用不透明背景,降低拖动窗口时的透明闪烁感。\n- **Logo 默认动画导致界面闪烁** - 关闭 Logo 默认动画,避免页面初始渲染和切换时出现不必要的视觉闪烁。\n- **Responses 单渠道模式未追踪对话数据** - 修复 Responses 单渠道成功请求未写入对话追踪器的问题,使驾驶舱能够正确展示单渠道对话数据。\n\n### 其他\n\n- **补充 Responses 单渠道对话追踪回归测试** - 为 Responses 单渠道成功请求新增 handler 级测试,验证请求会写入 `ConversationTracker` 并生成驾驶舱对话记录,防止 `/conversations` 再次为空。\n\n## [v2.8.2] - 2026-05-25\n\n### 新增\n\n- **桌面端最小化后隐藏 macOS Dock 图标** - macOS 下窗口最小化后同步隐藏 Dock 图标,减少后台运行时的 Dock 干扰。\n- **桌面端路径项支持打开目录/编辑器操作** - Agent 相关路径展示新增纯图标操作按钮,可直接打开目录或用编辑器打开配置文件。\n\n### 修复\n\n- **桌面端渠道预设模型重定向键统一为 `gpt-5`** - MiMo、MiniMax、OpenCode Zen、OpenCode Go 的 Chat / Codex Responses 单模型重定向现在统一使用 `gpt-5` 作为源模型键,匹配 Codex / Chat 客户端实际请求模型;同时集中整理渠道预设的模型映射、reasoning 映射和 Codex 兼容开关配置。\n- **桌面端中文系统默认语言仍为英文** - 环境配置表单在 `.env` 缺少 `APP_UI_LANGUAGE` 时会读取 WebView 暴露的系统/浏览器语言,中文系统默认填入 `zh-CN`,非中文系统继续默认 `en`,且不会覆盖用户已有 `.env` 设置。\n- **桌面端 MiniMax 渠道预设与 Codex 兼容配置不完整** - MiniMax Responses 预设现在使用 `gpt-5 -> MiniMax-M2.7` 模型映射,并启用非标准 Chat 角色归一化;同步补充渠道预设测试对归一化字段的断言。\n- **桌面端 Codex 应用 CCX 渠道时 auth 密钥不一致** - Codex Agent 配置现在优先读取桌面 dataDir、项目根目录和 `backend-go` 下 `.env` 中的 `PROXY_ACCESS_KEY`,再回退到进程环境变量或自动生成值,避免 `~/.codex/auth.json` 写入与实际后端配置不一致的密钥;同时增强 `.env` 解析以兼容 `export PROXY_ACCESS_KEY=...` 和等号周围空格。\n- **能力测试复制到其他 Tab 时原生协议类型错误** - 当能力测试中某个原生协议测试成功后复制渠道到其他协议 Tab 时,新增渠道的 `serviceType` 现在按成功协议映射为对应原生上游类型(Claude Messages / OpenAI Chat / Gemini / Responses),不再沿用源渠道的协议类型;转换按钮同时保留目标 Tab 与成功协议两个语义,避免复制到目标 Tab 时写入错误上游类型。\n- **桌面端初始配置完成状态误读环境变量** - `IsSetupComplete` 现在只读取桌面 dataDir 下的 `.env`,避免进程环境变量或项目根目录 `.env` 让首次启动引导被错误跳过,并补充对应单元测试。\n- **桌面端 Agent 状态路径展示溢出** - Agent 卡片中的当前/目标 URL、配置文件和认证文件路径改为固定标签列 + 可换行内容列,保留编辑器打开按钮且避免长路径挤压布局。\n- **桌面端网关监控详情字段未对齐** - 网关监控详情展示字段与后端服务状态字段保持一致,避免状态信息展示不完整或错位。\n- **桌面端托盘隐藏 Dock 图标时崩溃** - 修复托盘状态下触发隐藏 Dock 图标可能导致桌面端崩溃的问题。\n- **桌面端渠道预设刷新按钮点击报错** - 移除无效的刷新预设按钮,避免点击后触发前端异常。\n- **桌面端页面内容区宽度受限** - 移除页面内容区 `max-w-5xl` 限制,使桌面端页面能够自适应占满可用空间。\n\n### 文档\n\n- **补充渠道预设模型重定向说明** - 在变更记录中补充渠道预设模型重定向的说明,明确 Codex / Chat 客户端实际请求模型与预设映射关系。\n- **调整赞助商展示样式** - 优化 README 中赞助商展示区域的排版与视觉呈现。\n- **添加赞助商注册链接** - 在 README 赞助商信息中补充可直接访问的注册链接。\n- **添加优云智算赞助商信息** - README 新增优云智算赞助商介绍与展示图片。\n\n### 其他\n\n- **version-bump 增强 CHANGELOG 完整性校验** - 版本升级技能现在会校验从上一版本 tag 到 HEAD 的提交记录,补全或移动遗漏的 CHANGELOG 条目。\n- **桌面端 Wails 绑定产物切换为 TypeScript** - 当前工作区包含 `desktop/frontend/bindings/` 下生成绑定从 `.js` 文件删除到 `.ts` 文件新增的变更,需随本轮未提交改动一起审核。\n\n## [v2.8.1] - 2026-05-25\n\n### 修复\n\n- **桌面 App 内嵌 Web UI 删除渠道不生效** (#115) - Wails 3 的 WKWebView 在 iframe 上下文中对原生 `window.confirm()` 不弹对话框、静默返回 `false`,导致删除请求从未发出;替换为 Vuetify `v-dialog` 实现的通用确认框,兼容桌面 iframe 与浏览器双入口\n- **LogViewer autoScroll 开关失效,新日志仍强制滚动** - 移除对 `filteredLogs` 的冗余 watch,改为监听 `searchQuery` 仅在用户改变搜索关键字时强制滚动;新日志增加时改走受 `autoScroll` 控制的 `props.logs.length` watch\n- **缩短引导界面生成的 PROXY_ACCESS_KEY 长度** - 随机熵从 24 字节降为 8 字节,密钥从 `ccx-` + 48 字符 hex(52 字符)改为 `ccx-` + 16 字符 hex(20 字符);64 位熵对本地代理网关已足够安全,显著改善初始引导界面的可读性与可复制性\n\n### 文档\n\n- **修正渠道名称说明,反映同名覆盖的实际行为** - 后端 `createChannel` 在检测到同名渠道时会执行 PUT 覆盖更新,原文案 \"如重复,CCX 会拒绝创建\" 与实际行为不符,容易造成误导\n\n## [v2.8.0] - 2026-05-25\n\n### 修复\n\n- **TestDetectRootDirFallsBackToCwd 在 macOS `/private/var` 符号链接下的误报** - macOS 的 `/var` 是 `/private/var` 的符号链接,`t.TempDir()` 返回前者但 `os.Getwd()` 解析到后者,导致字符串直接比较失败;改用 `filepath.EvalSymlinks` 对齐期望值\n\n### 其他\n\n- **同步 channelpreset 测试期望与当前 preset 实现** - 修复 9 个因测试期望过时导致的失败用例,实现本身正确\n - 移除对 `SupportedModels` 字段的断言:`preset.go` 从未设置该字段(deepseek_chat 与 mimo 共 4 个用例)\n - `glm_chat` / `glm_responses` BaseURL 改为 coding plan 的实际值(`https://open.bigmodel.cn/api/coding/paas/v4#`),匹配 `bestPlanForTarget` 按 plan 数组顺序选第一个非 anthropic plan 的行为\n - `minimax_responses` 的 `CodexToolCompat` / `StripCodexClientTools` 改为 false:`applyTargetDefaults` 显式 override 为 native passthrough 策略,与 DeepSeek 一致\n - `TestBuildPayloadRejectsUnsupportedTarget` 改用 `invalid-target`:kimi preset 已支持 `NativeMessages`,原 kimi+messages 用例失效\n\n## [v2.7.31] - 2026-05-25\n\n### 破坏性变更\n\n- **桌面端 dataDir 路径稳定化,移除 sha1 hash 子目录** - 历史版本将 dataDir 计算为 `{UserConfigDir}/ccx-desktop/{sha1(rootDir)[:10]}`,rootDir 由 `os.Getwd()` 决定,导致从 Dock、Spotlight、终端 `wails3 dev` 等不同方式启动时产生不同 hash 目录(实测一台机器下出现 3 个互不相通的目录),用户的 PROXY_ACCESS_KEY、渠道配置、Agent 配置快照在不同启动方式之间相互不可见\n - 新版本统一使用 `{UserConfigDir}/ccx-desktop/`,与 `bootstrap.log` 同级,dev 与 prod 共用\n - **升级影响**:从旧版本升级后,原 hash 子目录(如 `ccx-desktop/42099b4af0/`)不会自动迁移,用户首次启动会进入空配置状态,需要重新通过引导页设置或手动从旧 hash 目录复制 `.env`、`agent-config-state/`、`.config/` 到新位置\n - 不做自动迁移的原因:无法可靠判断哪个 hash 目录代表\"主用户数据\"\n\n## [v2.7.30] - 2026-05-25\n\n### 新增\n\n- **桌面端首次启动引导流程** - 强制要求用户在启动 CCX 服务前完成最小可用配置,避免直接进入主界面后调用失败\n - 新增 SetupView 全屏引导页:检测 `.env` 中无 `PROXY_ACCESS_KEY` 时显示,预填随机生成的密钥(`ccx-<48hex>`),支持显示/隐藏、复制、重新生成\n - 完成后自动合并写入 `.env`(保留已有键值与注释),自动启动 CCX 服务并跳转至【渠道中心】标签页\n - 引导页展示 `.env` 文件路径方便后续调试,提示后续可在【环境参数】页继续修改其他字段\n - 新增 `IsSetupComplete` / `GenerateProxyAccessKey` 两个 Wails 绑定方法,前者只读判断密钥存在性,后者仅生成预览不写入文件\n - App 启动时三态渲染(Loading 占位 / Setup 引导页 / 主界面),避免界面切换闪烁\n\n- **桌面端 bootstrap 启动日志** - 在 backend manager 初始化前写入固定位置的 `bootstrap.log`,覆盖 dataDir 计算阶段的启动诊断盲区\n - 新增 `mustGetwd` / `mustExecutable` 辅助函数,记录关键启动阶段\n - 便于 Windows 双击无反应、dataDir 解析异常等场景的故障排查\n\n### 修复\n\n- **桌面端版本号显示重复 `vv` 前缀** - 修复桌面端左下角及更新弹窗版本号显示为 `vv2.7.29` 的问题\n - VERSION 文件已自带 `v` 前缀,前端模板不应再硬编码 `v`\n - 修正 `Sidebar.vue` 左下角版本徽标,以及 `UpdateDialog.vue` 标题处的当前/最新版本对比\n\n- **detectRootDir 向上遍历边界条件死循环** - 修复 `filepath.Dir` 到达根目录后返回自身导致的潜在死循环\n - 改为显式检测 `parent == dir` 终止\n - 新增测试验证 fallback 到 cwd 的行为\n\n### 其他\n\n- **NSIS 构建添加 `/INPUTCHARSET UTF8`** - 修复 NSIS 在非 ASCII 路径下编译失败的问题,并将 `ccx-go.exe` 构建产物加入 `.gitignore`\n- **新增 `make install` 命令** - 一键安装根目录、`backend-go/`、`frontend/`、`desktop/`、`desktop/frontend/` 全部依赖\n\n## [v2.7.29] - 2026-05-25\n\n### 修复\n\n- **批量延迟测试在 Chat 标签页报错 `e.forEach is not a function`** - 修复 Chat 渠道批量延迟测试时,前端 `pingAllChatChannels()` 未解包后端 `{\"channels\": [...]}` 响应导致 `forEach` 调用失败的问题\n - `api.ts` 中 `pingAllChatChannels()` 现在正确提取 `resp.channels` 并映射字段,与 Images/Gemini 处理方式一致\n\n- **第三方 provider 直连使用 ANTHROPIC_AUTH_TOKEN** - 修复桌面端第三方 provider(DeepSeek、MiMo、Kimi、GLM、MiniMax、DashScope、OpenCode)直连 Claude Code 时,API Key 错误写入 `ANTHROPIC_API_KEY` 的问题,改为正确写入 `ANTHROPIC_AUTH_TOKEN`\n - `resolveClaudeProvider` 统一将第三方 provider 的 key 归入 `authToken` 返回值,与 CCX provider 行为一致\n - apply、restore、preview 三条路径均通过同一函数解析,无需额外修改\n\n- **配置预览 Diff 掩码后丢失敏感字段变更** - 修复 Agent 配置预览弹窗中,敏感字段(如 `ANTHROPIC_AUTH_TOKEN`)变更时因掩码为 `***` 导致 diff 无法识别变更的问题\n - 新增 `computeJSONDiffWithMask` / `computeTextDiffWithMask`:先在原始内容上做 LCS diff 正确识别变更类型,再用掩码后的内容填充展示行\n - 更新全部 6 个 preview 函数(Claude/Codex apply & restore)使用先 diff 再掩码的方式\n - 新增 `extractNestedStringValues` 支持从嵌套 map(如 `env` 子 map)提取敏感字段值\n - 新增 4 个单元测试覆盖掩码 diff 核心场景\n\n## [v2.7.28] - 2026-05-24\n\n### 修复\n\n- **Codex 第三方 provider 状态识别与恢复逻辑修复** - 修复桌面端 Codex 直连模式下第三方 provider 的运行状态识别和恢复逻辑\n- **Codex Responses 转 Chat 时空 tool_choice 导致上游 400** - 修复当 Codex 请求仅携带非 function 类型 tools(全被过滤)时,`tool_choice` 和 `parallel_tool_calls` 仍被透传给上游 Chat API 导致拒绝的问题\n - `OpenAIChatConverter` 和 `ConvertResponsesToOpenAIChatRequest` 两条路径均增加 tools 存在性检查\n - 无 tools 时自动剥离 `tool_choice` 和 `parallel_tool_calls`,与透传路径 `stripCodexClientOnlyTools` 行为一致\n\n- **WebUI iframe 切换标签时自动刷新** - 修复桌面端切换标签页后 WebUI iframe 白屏的问题,切回 WebUI 标签时自动刷新内容\n\n- **Windows 桌面端双击无反应** - 修复 Windows 生产构建(`-H windowsgui`)下启动失败时错误静默吞掉的问题\n - 添加 Windows MessageBox 弹窗:WebView2 缺失、ccx-go 找不到等致命错误现在会弹窗提示用户\n - 添加顶层 `recover()` 兜底:未处理的 panic 不再导致进程静默退出\n - 添加单实例互斥锁:重复双击时弹窗提示\"已在运行中\",检查系统托盘\n - 添加持久化文件日志:`%APPDATA%\\ccx-desktop\\\\desktop.log` 记录启动日志便于排查\n - NSIS 安装器 `ccx-go.exe` 从可选改为必需,缺失则安装失败\n\n## [v2.7.27] - 2026-05-24\n\n### 新增\n\n- **Codex 直连新增 DashScope / OpenCode Zen / Go 选项** - 桌面端 Codex 直连配置新增 DashScope、OpenCode Zen 和 OpenCode Go 三个新的 provider 选项\n- **DashScope chat/responses 预设扩展** - DashScope 渠道预设增加 GPT 模型重定向和 reasoning mapping 支持\n\n### 修复\n\n- **Codex 直连目标 URL 显示** - 修复 Codex 直连时目标 URL 未正确显示各 provider 的 Responses 端点的问题\n\n## [v2.7.26] - 2026-05-24\n\n### 新增\n\n- **多渠道 Messages 原生透传支持** - Kimi、GLM、MiniMax 新增 Messages 原生透传,DeepSeek codex responses 预设默认开启原生工具透传和 role 规范化\n- **渠道预设扩展** - 新增 OpenCode Zen/Go 和阿里云 DashScope 渠道预设与 Agent 直连支持\n- **Agent 直连选项** - Agent 配置新增 Kimi / GLM / MiniMax 直连选项\n- **渠道中心自动覆盖** - 添加到 CCX 时自动覆盖同名渠道配置\n- **Web UI 刷新按钮** - 内嵌 Web UI 页面添加刷新按钮\n- **Codex OpenAI 切换** - 支持 Codex 无缝切换回 OpenAI provider\n\n### 修复\n\n- **渠道中心文案与 URL** - 修正描述文案错别字,移除 /v1 结尾 base URL 的 # 后缀\n- **渠道中心 target 切换** - 修复切换 target 后被级联重置回 messages 的问题\n- **桌面端二进制路径** - 修复 ccx-go 后端二进制未打包到 App Bundle 的路径错误\n- **vite 插件路径** - 修复 vite 插件路径为绝对路径并更新前端依赖\n- **OpenAI provider 配置** - 修复 OpenAI 模式下目标 URL 显示为空,移除无效配置块写入\n- **监控面板初始化** - 初始化时预探测二进制路径,避免监控面板显示\"未发现\"\n\n### 重构\n\n- **Web UI 刷新按钮位置** - 刷新按钮移至标题栏,WebUITab 通过 defineExpose 暴露刷新方法\n- **渠道预设精简** - 移除渠道预设中所有 SupportedModels 显式赋值,MiniMax 预设精简默认值\n- **ccx-go 查找路径** - 改为向上遍历最多 6 层目录\n\n### 其他\n\n- **Wails 升级** - Wails v3.0.0-alpha.92 升级到 alpha.95\n\n## [v2.7.25] - 2026-05-23\n\n### 修复\n\n- **渠道预设模型映射更新** - 更新桌面端渠道预设的模型映射配置\n- **WebUI 默认语言改为中文** - 前端 WebUI 默认语言从英文改为中文\n- **Agent 代理配置应用按钮文案统一** - 统一桌面端 Agent 代理配置的应用按钮文案\n- **Checksum 文件路径前缀修正** - 去除 CI Release 流程中 checksum 文件的路径前缀\n\n### 其他\n\n- **桌面应用图标资源更新** - 更新桌面应用图标资源文件\n\n## [v2.7.24] - 2026-05-23\n\n### 修复\n\n- **Release workflow Bad credentials 错误** - 修复 CI Release workflow 中 GitHub token 认证失败的问题\n\n## [v2.7.23] - 2026-05-23\n\n### 修复\n\n- **MiMo 不同计费模式支持独立 API Key** - ProviderKeyAsset 的 Assets map 改为 `provider:planID` 复合 key,同一 provider 不同 plan 不再互相覆盖;前端查找保存的 key 时优先匹配 plan-specific key,回退到 provider-only key 兼容旧数据\n\n## [v2.7.22] - 2026-05-23\n\n### 修复\n\n- **桌面端 CCX Provider 应用按钮状态修复** - CCX provider 的应用按钮不再依赖网关在线状态,避免网关未启动时无法操作\n- **Release checksum 路径修复** - 修复 Release checksum 生成路径错误导致三个主平台构建失败的问题\n\n## [v2.7.21] - 2026-05-23\n\n### 修复\n\n- **Release checksum 路径前缀修正** - 修复 CI release 流程中 checksum 文件路径前缀错误并精简 finalize job\n- **桌面构建 ccx-go 查找路径** - 修正桌面端构建时 ccx-go 后端二进制的查找路径\n- **渠道中心 Base URL 过滤** - 桌面端渠道中心按 target 协议过滤 Base URL 选项,避免显示不匹配的选项\n\n### 其他\n\n- **恢复 Wails 3 TypeScript 绑定文件** - 恢复桌面端 Wails 3 自动生成的 TypeScript 绑定文件\n- **version-bump 技能文档更新** - 更新 version-bump 技能的 GitHub Actions 集成说明,新增 `gh run list` 查看构建进度命令\n\n## [v2.7.20] - 2026-05-23\n\n### 修复\n\n- **MiMo 渠道模板 Base URL 修正** - 修正 MiMo 渠道模板 Base URL 为 Anthropic 协议入口,同时调整计费模式表单顺序\n- **网关监控默认显示配置端口** - 网关监控打开时默认显示 .env 中配置的端口,启动后端时优先使用用户配置的端口\n- **Windows Desktop amd64 安装包缺失** - 修复 CI 中 Windows Desktop amd64 安装包在 Release 中缺失的问题\n- **macOS 应用图标重新设计** - 重新设计应用图标以适配 macOS Human Interface Guidelines\n- **macOS 托盘图标交互修复** - 修复 macOS 托盘图标点击后窗口置顶和位置偏移的问题\n\n## [v2.7.19] - 2026-05-23\n\n### 修复\n\n- **日志文件混入精简格式重复内容** - 修复 logger 将 stdout 精简格式与原始完整 JSON 双重写入日志文件的问题,确保日志文件仅保留原始完整记录\n\n## [v2.7.18] - 2026-05-23\n\n### 修复\n\n- **DMG 安装体验与图标显示** - 改善 macOS DMG 安装包的安装体验与应用图标显示效果\n\n## [v2.7.17] - 2026-05-23\n\n### 修复\n\n- **Task 依赖链 ARCH 传递遗漏** - 修复 CI 中 Task 依赖链未正确传递 ARCH 环境变量的问题\n\n## [v2.7.16] - 2026-05-23\n\n### 修复\n\n- **桌面构建 ARCH 变量未通过 Task 依赖链传递** - 修复 CI 中桌面端构建时 ARCH 环境变量未正确传递到下游 Task 步骤的问题\n\n### 其他\n\n- **版本升级技能新增本地编译验证** - version-bump 技能在创建 tag 前强制执行本地测试和构建验证,编译失败时中止发布流程\n\n## [v2.7.15] - 2026-05-23\n\n### 变更\n\n- **移除后端 OTA 更新机制** - 删除后端 updater 包及相关 API 接口,桌面端更新改为跳转到 GitHub Releases 下载页面\n\n## [v2.7.14] - 2026-05-23\n\n### 新增\n\n- **桌面端 .env 密码字段复制按钮** - .env 配置中密码类型字段(如 `PROXY_ACCESS_KEY`)添加复制按钮,方便用户快速复制密钥值\n\n### 修复\n\n- **密码字段留空时隐藏复制按钮** - 当密码类型字段值为空时自动隐藏复制按钮,避免无效操作\n\n### 变更\n\n- **移除后端 OTA 自动更新** - 删除 `updater` 包、`/api/update/check` 接口及相关配置,桌面端更新改为跳转到 GitHub Releases 下载页面\n\n### 其他\n\n- **Release workflow 桌面构建前置修复** - CI 桌面端构建步骤前确保 `bin` 目录存在,修复构建流程中目录缺失问题\n\n## [v2.7.13] - 2026-05-23\n\n### 新增\n- **Linux ARM64 桌面端支持**:CI 新增 `ubuntu-24.04-arm` runner 构建 arm64 AppImage,updater 移除 arm64 限制。\n- **前端测试框架**:引入 vitest + jsdom,新增 `env-file.ts` 20 个单元测试。\n- **Bindings 同步 CI 守护**:新增 `check-bindings.yml` workflow,PR 修改 Go 接口时自动验证 TypeScript bindings 同步。\n- **桌面端 .env 文件外部编辑器打开**:Env 配置页新增\"用编辑器打开\"按钮,自动检测系统已安装的编辑器(VS Code、Cursor、Sublime Text、Vim 等),支持 `$EDITOR` / `$VISUAL` 环境变量优先。多个编辑器时提供下拉选择。\n\n- **Desktop 内置 ccx-go 后端**:macOS .app、Windows NSIS、Linux AppImage/deb/rpm 安装包现在内置 ccx-go 后端二进制,用户无需单独下载。Desktop 与 ccx-go 共用版本号。\n- **Release Sigstore 签名**:\n - 在 CI 发布流程中集成 Sigstore / cosign keyless signing,使用 GitHub Actions OIDC 颁发短期证书,无需管理密钥。\n - 三个构建平台(macOS / Windows / Linux)各自生成平台级 checksums 并签名,`finalize` job 合并为全平台 `checksums.txt` 后再次签名。\n - 新增 `docs/guide/verification.md` 与 `docs/en/guide/verification.md` 验证文档,说明 cosign 安装与签名验证步骤。\n - 现有 `.sha256` sidecar 文件与 updater 行为不变。\n\n### 变更\n\n- **桌面端 Env 配置访问控制置顶**:`PROXY_ACCESS_KEY` 和 `ADMIN_ACCESS_KEY` 配置组在 Env 配置表单中移到首位,便于首次配置时优先设置。\n- **补齐桌面端测试覆盖**:新增 `configservice`(19 个纯函数 + 集成测试)和 `backend/manager`(16 个测试)测试文件。\n\n### 修复\n- **MSIX 打包缺少 wails.json**:新建 `desktop/wails.json`,修复 `wails3 tool msix --config` 引用不存在的文件。\n- **parseEnvContent 兼容性**:Go 端 `.env` 解析支持 `export` 前缀和 `\\r\\n` 换行符。\n- **桌面端 Agent 配置服务初始化错误日志**:`configservice.New()` 失败时不再静默丢弃错误,改为输出 `[Desktop-Init]` 日志便于排查。\n- **渠道对话框下拉菜单位置偏移**:修复 Vuetify v-select/v-combobox 在添加/编辑渠道对话框内首次打开时菜单位置计算错误的问题,通过 eager 预渲染与 resize 触发确保下拉菜单正确定位。\n- **DeepSeek `user_id` 限速与隔离字段透传**:\n - 让内部会话标识提取支持 Chat 请求体顶层 `user_id`,用于 Trace 亲和性与调度追踪。\n - 补齐 Chat → Claude、Messages → OpenAI/Responses、Responses → Claude 跨协议转换中的 `user_id` / `metadata.user_id` 映射,避免用户隔离标识在协议转换时丢失。\n - 增加相关单元测试覆盖关键转换路径。\n- **Responses 转 Claude 上游 reasoning 回传补齐**:\n - 在 Responses 转 Claude/Messages 风格请求且启用 `PassbackReasoningContent` 时,同步补齐 `reasoning_content`,避免 MiMo 等上游跨渠道历史请求缺少 reasoning 回传字段。\n\n## [v2.7.12] - 2026-05-22\n\n### 修复\n\n- **Windows Release 构建 NSIS makensis 不在 PATH** - 修复 CI 中 Windows Release 构建时 NSIS `makensis` 工具不在 PATH 导致构建失败的问题\n\n## [v2.7.11] - 2026-05-22\n\n### 新增\n\n- **渠道预设 Token Plan 下拉显示实际 Base URL** - 桌面端渠道预设 Token Plan 下拉选项现在会显示实际的 Base URL,便于用户识别和选择\n\n## [v2.7.10] - 2026-05-21\n\n### 修复\n\n- **MiMo Messages 渠道流式响应只输出 thinking 的问题**:\n - 停止为缺少真实思考内容的历史 assistant 消息注入 `\"(no prior reasoning recorded)\"` 占位 `thinking` 或 `reasoning_content`,避免 MiMo 将正式回答续写进假 thinking block,或继续因假回传返回 `reasoning_content ... must be passed back` 400。\n - 为历史 assistant 消息补齐 MiMo 要求的顶层 `reasoning_content` 字段:保留真实 `thinking` 块原文并同步回传到顶层字段,仅移除历史请求中旧版本注入的占位/空 `thinking` 块;若 assistant 历史没有真实思考内容,则补空字符串 `reasoning_content: \"\"`(保持顶层字段存在);若历史 assistant 仅剩占位 thinking,则转为中性非空 text,避免空 content 或轮次丢失触发上游校验问题。\n - `thinking -> reasoning_content` 转换改为“保真搬运”:不再对真实思考文本做 trim/拼接改写;当消息已含顶层 `reasoning_content` 时保持原值,避免内容被改写后触发上游回传一致性校验失败。\n - 修复请求预处理误删真实 thinking 的问题:`thinking` 块里 `signature` 为空/null 时仅移除 `signature` 字段,不再删除整块 `thinking`,避免后续 `reasoning_content` 回传被掏空。\n - 增强 failover 非重试判定:当上游 400 的 `error.param`/`error.message` 命中 `reasoning_content in the thinking mode must be passed back` 时,判定为 schema 参数错误,不再继续 Key/渠道级 failover,避免请求漂移到其他渠道与错误熔断。\n - 流式预检测改为只有正式 text 或工具语义内容才视为有效响应;thinking-only 流会被判定为空响应并触发重试/失败,避免静默返回 200。\n\n## [v2.7.9] - 2026-05-20\n\n### 修复\n\n- **Gemini 渠道流式响应下 tool call ID 生成策略优化**:\n - 在 `backend-go/internal/providers/gemini.go` 中,将 Gemini 流式响应下 functionCall 的 tool ID 生成策略从不稳定的索引拼接形式(如 `toolu_%d`)优化为稳定的、带有 `call_` 前缀的 16 字节随机 hex 字符串(通过 `crypto/rand` 生成),使 ID 在跨多轮对话时更具唯一性且对齐主流(如 OpenAI `call_xxx` 风格),避免在复杂 agent 多轮调用中发生冲突。\n - 在 `backend-go/internal/providers/gemini_stream_test.go` 中添加对应的测试断言,验证 tool call ID 的 `call_` 前缀生成逻辑。\n- **修复 Gemini 流式响应中 functionCall 场景的 `stop_reason` 映射**:\n - 在 `backend-go/internal/providers/gemini.go` 中,引入 `hasToolUse` 状态标志。当流式响应中检测到 functionCall 时,将 `message_delta` 的 `stop_reason` 正确映射为 `tool_use`(而不是默认的 `end_turn`),确保下游消费端能够正确感知并启动工具调用执行,完全符合 Claude 规范。\n - 在 `backend-go/internal/providers/gemini_stream_test.go` 中新增 `TestGeminiHandleStreamResponse_FunctionCallMapsStopReasonToToolUse` 单元测试,模拟 functionCall 流式响应并验证 `stop_reason` 映射。\n\n## [v2.7.8] - 2026-05-20\n\n### 重构\n\n- **清理 `providers` 包历史 lint 告警**:\n - `backend-go/internal/providers/openai.go` 中 `ConvertToClaudeResponse` 转换 tool_call 入参时,给 `json.Unmarshal` 补上错误检查;解析失败时降级保留 `toolCall.Function.Arguments` 原始字符串,避免静默丢失参数(errcheck)。\n - `backend-go/internal/providers/openai_stream_usage_test.go` 中 `TestOpenAIProvider_ConvertToClaudeResponse_CacheFieldInJSON` 给 `json.Unmarshal` 补上 `t.Fatalf` 错误检查,防止 unmarshal 失败时后续断言基于空 map 假阳性通过(errcheck)。\n - `backend-go/internal/providers/responses.go` 删除未被任何地方调用的 `formatFunctionCallHistory` 函数(13 行):与配套 `formatFunctionCallOutputHistory` 不对称——`function_call` 分支始终直接保留原始 item,不需要降级为文本消息,该函数是早期设计残留(unused)。\n - `backend-go/internal/providers/claude.go` 中 `ConvertToProviderRequest` 模型重定向条件 `upstream.ModelMapping != nil && len(upstream.ModelMapping) > 0` 简化为 `len(upstream.ModelMapping) > 0`(Go 对 nil map 的 `len()` 定义为 0,gosimple/S1009)。\n\n## [v2.7.7] - 2026-05-20\n\n### 修复\n\n- **`messages` 走 Gemini 上游时 tool_result 函数名错位导致工具调用沉默丢失**:\n - 修复 `backend-go/internal/providers/gemini.go` 的 `convertMessage` 在转换 Claude `tool_result` 为 Gemini `functionResponse` 时,把 `name` 字段直接填成 `tool_use_id` 的问题。Gemini 协议要求 `functionResponse.name` 必须等于前面对应 `functionCall.name`(函数名),否则上游无法匹配到对应的工具调用,会沉默返回空内容(典型表现:MCP / 多轮工具历史在 Gemini 上游被静默丢弃,下游模型回合显示空白)。\n - 新增 `buildToolUseIDNameMap`,在 `convertMessages` 入口先扫一遍 Claude 历史,构建 `tool_use_id → name` 映射;`convertMessage` 接收该映射并在 tool_result 转换时回查正确函数名。查不到映射的孤立 tool_result 回退使用 `tool_use_id` 兜底,避免完全丢字段。\n - 在 `backend-go/internal/providers/gemini_tool_result_test.go` 中将历史 fixture 升级为含 tool_use 的完整对话,并新增针对 id→name 映射回查的断言用例。\n\n### 新增\n\n- **Chat 渠道透传思考回传支持**:\n - 在 `backend-go/internal/handlers/chat/handler.go` 中引入了与 Messages 渠道一致的预处理逻辑,自动清理空 signature 字段和历史畸形 thinking 内容块,预防上游参数校验 400 错误。\n - 在 `buildProviderRequest` 中,当 `PassbackReasoningContent` 关闭时,在发送给 Claude 协议上游前自动剥离历史 thinking 块,避免跨上游复用签名导致 signature 错误。\n - 在 `backend-go/internal/config/config_chat.go`、`channels.go` 和 `channel_metrics_handler.go` 中同步支持了 `PassbackReasoningContent` 字段的更新与返回。\n - 修改了 `frontend/src/components/AddChannelModal.vue`,使得“回传 Reasoning Content”开关在 Chat 渠道且服务类型为 claude 时也能正常显示和配置。\n - 在 `deepseek_thinking_matrix_test.go` 中新增了 `TestChatHandler_PassbackReasoningContent` 测试用例。\n\n### 修复\n\n- **`messages` 走 Gemini 上游时 MCP / 自定义工具历史丢失导致重复调用**:\n - 修复 `backend-go/internal/converters/responses_to_gemini.go` 中 `responsesItemToGeminiContents` 漏掉 `custom_tool_call` 与 `custom_tool_call_output` 两类 item 的问题。归一化阶段会把 MCP 工具(如 `mcp__serena__*`)落到这两种类型,原实现走 switch 的隐式 default 直接丢弃,导致历史里只剩模型空回合,Gemini 视为\"工具调用未返回\"并不停重复发起同一工具调用。\n - 现在两类 item 分别转为 `model` 角色的 `FunctionCall`(携带 `DummyThoughtSignature`,与 `function_call` 行为一致)和 `user` 角色的 `FunctionResponse`,并对缺 `call_id`/`name` 的孤立项做兜底丢弃,避免构造出 Name 为空的 part 触发上游 400。\n - 在 `backend-go/internal/converters/gemini_responses_roundtrip_test.go` 新增 `TestResponsesToGeminiRequest_PreservesCustomToolCallHistory` 和 `TestResponsesToGeminiRequest_CustomToolCallOutputWithoutCallIDDropped` 两条回归用例。\n- **SUBSCRIPTION_NOT_FOUND 余额不足故障转移**:\n - 将 `SUBSCRIPTION_NOT_FOUND` 错误码和 `\"no active subscription found\"` 错误信息识别为余额不足(`insufficient_balance`),从而正确触发渠道黑名单和故障转移。\n - 在 `failover_test.go` 和 `stream_test.go` 中补充了对应的普通请求与流式请求测试用例。\n- **Gemini 渠道 tool_result 数组解析报错**:\n - 修复了 `messages` 接口转上游 `gemini` 类型渠道时,`tool_result` 包含数组(Content Blocks)导致的反序列化报错问题。\n - 将 `GeminiFunctionResponse.Response` 字段类型从 `map[string]interface{}` 变更为 `interface{}`,提高结构体容错性。\n - 在 `backend-go/internal/providers/gemini.go` 的 `convertMessage` 方法中,对 `tool_result` 的 `content` 进行了智能解析和规范化,确保转换后的 `response` 始终是一个符合 Gemini 官方协议要求的 JSON 对象。\n - 新增了 `gemini_tool_result_test.go` 单元测试,覆盖了 `tool_result` 为数组、字符串、JSON 对象等各种场景,验证其能正确转换为 Gemini 期望的格式。\n- **Gemini 渠道空 text part 触发上游 400**:\n - 修复了 `messages` 接口转上游 `gemini` 类型渠道时,Claude assistant 消息中常出现的空 text 块(如带 tool_use 时的前置 padding)被无脑翻译为 `{\"text\": \"\"}`,被严格按 Gemini protobuf 校验的上游(如 vip.undyingapi.com)判定为 `contents[X].parts[Y].data: required oneof field 'data' must have one initialized field` 返回 400 的问题。\n - 在 `backend-go/internal/providers/gemini.go` 的 `convertMessage` 中处理 `text` 类型 content 时跳过空字符串,确保不向 Gemini 上游下发无意义的空 Part。\n - 在 `gemini_tool_result_test.go` 中新增 `TestGeminiProvider_ConvertMessage_SkipsEmptyTextBlock` 与 `TestGeminiProvider_ConvertMessage_KeepsNonEmptyTextBlock` 两条用例,覆盖空 text 块被剔除与非空 text 块仍保留两种场景。\n- **Claude→Gemini provider 转换遵循 thought_signature 渠道开关**:\n - 修复了 `messages` 接口转上游 `gemini` 类型渠道时,Claude 协议本身不携带 thought_signature 字段,导致严格校验的上游(如 vip.undyingapi.com)返回 `Function call is missing a thought_signature in functionCall parts` 400 的问题。\n - 在 `backend-go/internal/providers/gemini.go` 的 `convertToGeminiRequest` 中按上游 `injectDummyThoughtSignature` / `stripThoughtSignature` 开关注入 part 层级的 `thoughtSignature`,与原生 Gemini 入口(`handlers/gemini/handler.go`)的策略对齐:默认不修改、`injectDummyThoughtSignature` 开启时注入 `DummyThoughtSignature`、`stripThoughtSignature` 优先级更高且在该场景为 no-op(Claude 协议本就无签名)。\n - 在 `gemini_tool_result_test.go` 中新增 `TestGeminiProvider_ConvertToGeminiRequest_InjectDummyThoughtSignature`、`TestGeminiProvider_ConvertToGeminiRequest_DefaultNoSignature`、`TestGeminiProvider_ConvertToGeminiRequest_StripThoughtSignatureNoOp` 三条用例。\n- **补齐多渠道 thought_signature 字段链路**:\n - `backend-go/internal/config/config_messages.go` 的 `UpdateUpstream` 补充接收 `StripThoughtSignature`(之前漏了,开启了等于无效)。\n - `backend-go/internal/config/config_chat.go`、`config_responses.go` 的 update 函数补充接收 `InjectDummyThoughtSignature` 和 `StripThoughtSignature`,对齐 [[feedback_channel_config_field]] 的\"五类渠道更新函数必须同步应用新字段\"规则。\n - `backend-go/internal/handlers/messages/channels.go`、`chat/channels.go`、`responses/channels.go` 的 `GetUpstreams` 把这两个字段透出给前端;`handlers/channel_metrics_handler.go` 的 dashboard 端点同步补充。\n - `frontend/src/components/AddChannelModal.vue` 把 `injectDummyThoughtSignature` 开关的显示条件由仅 `props.channelType === 'gemini'` 改为 `(gemini || messages) && form.serviceType === 'gemini'`;`stripThoughtSignature` 开关的显示条件由仅 gemini 改为 `form.serviceType === 'gemini'` 且 channelType 属于 gemini/messages/chat/responses。chat/responses 渠道当前在 handler/converter 层默认无条件注入 dummy 签名,因此前端不暴露 `injectDummy` 开关以避免误导。\n- **`maskKey` 短密钥脱敏过度**:\n - 修复了 `backend-go/main.go` 中启动日志 `maskKey` 对长度 ≤ 4 的密钥直接输出 `****`、完全遮蔽用户能识别的字符的问题。\n - 现在对长度 ≤ 3 的密钥保留首字符(如 `abc` → `a****`),长度 4~8 保留首尾字符,长度 > 8 保留前后各 2 字符。\n\n### 新增\n\n- **CCX 桌面外壳 MVP** - 新增 Wails3 桌面外壳,用于将现有 CCX 后端作为核心服务构件进行启动、停止、重启、托盘驻留和状态监控;外壳通过现有 `/health` 探活并在内置标签页中加载 CCX Web UI,避免改动核心代理、调度和现有 Web 管理界面逻辑。\n - 新增 `desktop/` Wails3 项目,包含后端子进程 supervisor、托盘菜单、状态页、内嵌 Web UI 标签页和前端绑定。\n - 根 `Makefile` 新增 `desktop-dev` / `desktop-build`,复用现有前端 embed 与 Go 后端构建流程。\n - 调整 `.gitignore`,保留 Wails `desktop/build/` 源配置,同时继续忽略桌面构建产物。\n\n### 修复\n\n- **桌面外壳 Wails Runtime 422** - 升级 `wails3` CLI 与 `@wailsio/runtime` 客户端到 alpha.92 / alpha.79,匹配 alpha.79 引入的 binding transport refactor(请求改用 JSON body),消除旧 CLI(alpha.40)读 URL query 时的 `missing object value` 422 错误,桌面事件订阅(`desktop:show-tab` / `desktop:tray-error`)和 typed binding 调用恢复正常;同步升级前端工具链 vite 8 / vue-tsc 3 / typescript 6 / vue 3.5。\n- **空/畸形 Tool Call 自动重试** - 在 Fuzzy 模式下将空参数或非法 JSON 的 tool/function call 视为空响应并复用现有 failover,降低上游偶发 `Read({})` 等畸形工具调用对下游客户端的影响\n - 影响模块:Messages/Chat/Responses/Gemini 的非流式空响应判定、Messages/Responses 流式预检、Chat 流式写头前缓冲预检\n\n## [v2.7.4] - 2026-05-18\n\n### 修复\n\n- **Mimo thinking mode 兼容性** - 修复 mimo(小米 MiMo)等 reasoning model 上游在多轮对话切换场景下返回 400 \"The reasoning_content in the thinking mode must be passed back to the API\" 的问题\n - 之前实现将 thinking 块转为顶层 `reasoning_content` 字段(OpenAI 风格),实测 mimo 的 Anthropic 协议下不认顶层 `reasoning_content`,仍返回 400\n - 新实现保留所有原有 thinking 块,对缺少 thinking 块的 assistant 消息注入占位 thinking 块 `{\"type\":\"thinking\",\"thinking\":\"(no prior reasoning recorded)\"}`,让 mimo 通过 thinking mode 校验\n - 仅在渠道开启 `passbackReasoningContent: true` 时生效,对其他渠道无影响\n - 注意:旧对话切到 mimo 时模型上下文缺少真实推理内容,可能出现幻觉/指令遵循下降(mimo 官方公告明示的代价)\n- **OpenAI Provider 缓存 Token 丢失** - 修复 OpenAI Provider 在协议转换(OpenAI/DeepSeek → Claude Messages)时丢弃上游 cache usage 字段的问题(#76)\n - 流式:`HandleStreamResponse` 现在从 terminal usage chunk(`choices: []`)中提取 `prompt_cache_hit_tokens`、`prompt_tokens_details.cached_tokens` 等字段,并注入到 final `message_delta.usage` 中\n - 非流式:`ConvertToClaudeResponse` 新增二次 raw parse,将 DeepSeek/OpenAI 格式的 cache 字段映射到 `CacheReadInputTokens`\n - Metrics:`annotatePromptTokensTotalForProvider` 扩展到 OpenAI Provider,确保缓存命中归一化口径与 Responses Provider 一致\n\n## [v2.7.3] - 2026-05-18\n\n### 优化\n\n- **Docker 构建优化** - 使用 Go 交叉编译替代 QEMU 模拟,优化多架构镜像构建流程并增加层缓存,显著缩短 CI 构建时间\n\n### 修复\n\n- **Mimo reasoning_content 回传修复** - 修复 `convertThinkingToReasoningContent` 在将 thinking 块提取为 `reasoning_content` 字段时未从 content 数组中移除 thinking 块的问题;当从 Claude 原生渠道切换到 mimo 渠道时,残留的 thinking 块导致 mimo 上游返回 400 \"The reasoning_content in the thinking mode must be passed back to the API\" 错误\n\n## [v2.7.2] - 2026-05-17\n\n### 新增\n\n- **缓存读写总统计** - 全局统计图表和模型统计图表新增缓存 Token 读写统计展示\n - 后端:`ModelHistoryDataPoint` 新增 `cacheCreationTokens`/`cacheReadTokens` 字段;`GetModelStatsHistory` 和全局统计的模型分桶聚合逻辑补充缓存 Token 累加\n - 前端 `GlobalStatsChart`:Summary cards 和 compact summary 新增缓存 R/W 统计(有数据时显示);Tokens 图表视图动态添加 Cache Read/Write 系列线\n - 前端 `ModelStatsChart`:新增 Cache 视图切换,展示按模型分组的缓存 Token 趋势\n - 前端 `api.ts`:`ModelHistoryDataPoint` 类型补充缓存字段\n\n## [v2.7.1] - 2026-05-17\n\n### 新增\n\n- **渠道统计对齐接入点统计** - 渠道 Key 趋势图新增 7d/30d 时间维度,并展示总请求次数、成功率、输入/输出 Token 汇总卡片,与接入点总览统计对齐 (#72)\n - 后端:`HistoryDataPoint` 补充 Token 字段;`MetricsHistoryResponse` / `ChannelKeyMetricsHistoryResponse` 新增 `summary` 汇总;Key 趋势接口移除 24h 上限,支持 30 天范围 SQLite 聚合\n - 前端:`KeyTrendChart` 新增 7d/30d 按钮、summary cards、长时间范围 x 轴日期格式\n - SQLite:新增 `idx_records_api_type_metrics_key_timestamp` 复合索引优化渠道级长范围查询\n\n### 改进\n\n- **Override 熔断自动清除** - 当驾驶舱设置的 override(next channel)序列中所有渠道均不可用(熔断)时,调度器自动清除该 override 而非仅跳过,避免前端 NEXT 标签长期残留\n- **渠道熔断状态下发前端** - `GetConversationChannelsByKind` API 返回的渠道信息新增 `circuitOpen` 字段,前端驾驶舱可实时显示渠道熔断状态(FUSED 标记)\n\n### 修复\n\n- **Claude 协议空 Text Block 兼容开关** - 修复严格校验的第三方 Claude 协议上游因 Claude Code 在 `tool_use` 前插入裸空 `{\"type\":\"text\",\"text\":\"\"}` 占位块而返回 400 的兼容性问题;新增 Messages 渠道 `stripEmptyTextBlocks` 开关,在转发前按需移除空 text block,并同步接通前端配置、渠道视图与回归测试。\n- **Responses SSE keep-alive** - 为 Responses 流式代理增加 SSE keep-alive 机制,每 15 秒向下游发送 `: keepalive` 注释行,防止 DeepSeek 等慢上游思考期间触发 Codex 客户端 idle timeout 断连 (#67)\n\n## [v2.7.0] - 2026-05-17\n\n### 新增\n\n- **Responses Compact 本地压缩** - 当 responses 渠道上游为非原生 Responses 类型(openai/claude/gemini)时,`/v1/responses/compact` 端点自动切换为本地 compact 模式:将对话历史格式化为 transcript,通过现有 converter 管线发送普通请求让模型生成摘要,再包装为 Responses 格式返回。支持流式/非流式跟随客户端、session 历史读取与 compact 结果写回、大输入截断保护。原生 responses 上游若返回 404/405/501 也会自动回退本地 compact\n- **SessionManager 新增只读查询与压缩会话创建** - 新增 `GetSessionByResponseID` 通过 responseID 只读查找 session;新增 `CreateCompactedSession` 创建压缩后的轻量会话并记录映射\n- **ResponsesProvider 提取公共请求构建方法** - 新增 `ConvertBodyToProviderRequest` 公共入口,接受 bodyBytes 参数复用现有 URL/转换/认证逻辑,供 compact 等场景调用\n\n### 改进\n\n- **Messages 渠道模型列表兼容国内 Claude 协议入口** - 当 `base_url` 以 `/anthropic`、`/claude`、`/messages` 结尾时(如 `https://api.deepseek.com/anthropic`),模型列表获取自动尝试三段候选 URL:当前路径 → 剔除协议尾段 → 纯域名根路径,解决国内服务商模型接口不在兼容协议子路径下的问题。管理端\"获取模型\"同步适配。\n\n---\n\n## 历史版本(v2.0.0-go ~ v2.6.99)\n\n> 以下为早期版本的里程碑摘要,`v2.7.0` 及之后保留完整条目。更细粒度的历史变更可通过 `git log` 查看。\n\n### v2.6.x(2026-02 ~ 2026-05)\n\n- **渠道能力测试** - 完成 4 协议互转、Codex 自定义工具兼容、RPM/超时/取消/重定向验证、结果缓存与多协议并发轮询。\n- **独立 Images 渠道** - 新增 `/v1/images/generations`、`/v1/images/edits`、`/v1/images/variations`,接入独立 failover、metrics、日志和前端管理。\n- **熔断器三态状态机** - 引入 `closed` / `open` / `half_open`、指数退避、单探针恢复、持久化和 Dashboard 状态展示。\n- **渠道日志生命周期** - 支持 `pending`、`connecting`、`first_byte`、`streaming`、`completed`、`failed`、`cancelled` 全链路追踪与前端实时刷新。\n- **会话调度看板** - 新增 Conversation Dashboard、ConversationTracker、OverrideManager 与按对话覆盖渠道序列能力。\n- **Responses Compact 本地压缩** - 非原生 Responses 上游自动通过 converter 管线执行本地 compact,并写回轻量会话。\n- **Vision 能力路由** - 新增 `noVision`、`noVisionModels`、`visionFallbackModel`,按图片输入动态跳过或降级模型。\n- **Thinking / Reasoning 兼容** - 支持 DeepSeek、MiMo、Gemini 等上游的 `thinking`、`reasoning_content`、`thought_signature` 双向转换和回传。\n- **Codex 工具兼容层** - 支持 Codex `apply_patch` 代理工具、字符串工具、native tool passthrough、工具调用回写和 `codexToolCompat` 统一开关。\n- **模型与路由能力增强** - 支持模型过滤包含/排除规则、`X-Channel` 指定目标渠道、渠道级代理、自定义请求头、多 BaseURL 等价去重。\n- **缓存与指标口径统一** - 归一 Responses / Messages 缓存命中率、Token usage、Key / Model 维度统计和历史分桶。\n- **自动恢复机制** - 支持 UTC 0/8/16 时段自动恢复黑名单 key、half-open 探测和 suspended 渠道最小激活。\n- **前端体验与性能优化** - 完成多语言管理界面、移动端导航、能力测试弹窗重构、SVG/Tooltip 常驻资源收敛和内存优化。\n- **关键修复** - 覆盖 Fuzzy 模式 5xx 误判、空响应 failover、metadata.user_id JSON 对象兼容、401 字符串认证错误拉黑、客户端取消误计失败等问题。\n\n### v2.5.x(2026-01)\n\n- **Failover 逻辑模块化** - 提取通用 failover 分类、重试和黑名单判定逻辑。\n- **Gemini 兼容增强** - `thought_signature` 可配置,补齐 Gemini CLI tools schema 与 Dashboard 字段。\n- **指标记录架构优化** - 修复二次计数、客户端取消计数、换 key 后历史累计、渠道删除后串桶等问题。\n- **渠道管理增强** - 支持渠道置顶/置底、前端模型映射智能选择、隐式缓存读取推断。\n- **关键修复** - 包括 Gemini functionDeclaration parameters 类型、Models API 日志标签、响应 model 字段改写可配置化。\n\n### v2.4.x(2025-12 ~ 2026-01)\n\n- **Gemini API 完整实现** - 新增 Gemini API 模块、路由、历史指标和前端管理界面。\n- **统计图表增强** - 新增全局流量 / Token 图、Key 趋势图、缓存命中率统计和多模型趋势展示。\n- **低质量渠道处理机制** - 支持 `lowQuality` 渠道标记、暂停清理促销期和输出验证日志。\n- **配置模块化拆分** - 按渠道类型拆分 config 包,减少单文件配置膨胀。\n- **多端点调度优化** - 支持多 BaseURL failover、预热排序、非阻塞动态排序和并发竞争修复。\n- **关键修复** - 覆盖流式工具调用分裂、空 signature 导致 Claude API 400、内容审核无限重试、403 预扣费降级、SSE 事件完整性等问题。\n\n### v2.3.x(2025-12)\n\n- **渠道级 API Key 策略与多 BaseURL** - 支持多 key / 多 URL 管理、促销期状态展示和延迟测试优化。\n- **SQLite 指标持久化** - 引入本地指标存储,补齐 Responses API Token usage 与 Messages 缓存 TTL 细分统计。\n- **快速添加渠道** - 支持协议自动检测、引号 / 等号 / 空格分割、`settings.json` 解析和多 Base URL 输入。\n- **Models API 端点** - 新增模型聚合查询能力并完善 API Key / Base URL 去重。\n- **关键修复** - 包括滑动窗口重建、Responses usage 缺失、缓存 token 检测、total_tokens 零值补全。\n\n### v2.2.x ~ v2.1.x(2025-12)\n\n- **Handlers 模块重构** - 将 handlers 重构为同级子包结构,完善 Stream 错误处理与 CountTokens 安全加固。\n- **指标系统重构** - 指标绑定到 Key 级别,修复熔断器、单渠道路径指标和非 failover 错误计数。\n- **错误处理与历史数据** - 新增 Fuzzy Mode 错误处理开关、渠道指标历史数据 API、Key 级使用趋势图和失败率可视化。\n- **协议兼容增强** - 支持 TransformerMetadata、CacheControl、BaseURL `#` 结尾语义和 FinishReason 统一映射。\n- **稳定性修复** - 覆盖请求体大小限制、goroutine 泄漏、数据竞争、优雅关闭、流式日志合成器类型等问题。\n\n### v2.0.x(2025-10 ~ 2025-12)\n\n- **Go 版本初始发布** - 完成核心功能移植、单文件部署和性能提升。\n- **核心组件落地** - 引入 ChannelScheduler、MetricsManager、TraceAffinityManager 等基础模块。\n"}, "784041ebc2a680321b17c44db2f89e95b9470dbe": {"t": 1785809861.831934, "n": 114679, "h": "734953b7098f3dc6", "c": "## [Unreleased]\n\n### 新增\n\n- **订阅中心与密钥管理体验** - 订阅中心改为渠道中心式 Provider 快捷接入,支持一键添加 GitHub Copilot、new-api 与手动订阅;三家赞助商展示真实 Logo/徽标并移除 Unity2.ai;API Key 支持手动暂停/恢复,模型覆盖按 Key 集合归组,火山折叠摘要及 Kimi/Compshare/MiniMax 余量改为统一五档阈值着色,Kimi 同时展示订阅总用量、赠送额度和加油包余额\n- **Autopilot Trace v2 契约与脱敏边界** - 新增 TraceDetailV2/TraceSummary/SchedulerDecisionSummary/EndpointAttemptSummary DTO,ComparisonStatus 三态枚举(matched/mismatched/uncompared)替代裸 bool,SanitizeForPersistence/SanitizeForResponse 双边界脱敏,crypto/rand 碰撞安全 TraceUIDv2,策略指纹(排除比例/seed),稳定哈希分桶,v1→v2 读适配器;新增 40+ L1 表驱动测试\n- **SQLite v6 迁移、持久化与清理** - autopilot schema v5→v6 幂等迁移;trace 表新增 schema_version/trace_revision/request_correlation_id/release_id/policy_fingerprint/persistence_class/details_json 列;窗口表重建主键增加 release/policy/cohort 维度和 compared/matched/mismatch/uncompared 计数;safety event 增加 release/policy 字段;in-flight 索引和异常提升落盘;有界异步 writer(512 队列/128 终态预留/64KiB 上限/250ms deadline/1s drain);7 天 trace + 30 天窗口清理策略\n- **Trace 关联接入请求生命周期** - 入口生成 requestCorrelationId 写入 gin context;选择渠道后写入 AutopilotTraceUID;ChannelLog 新增 RequestCorrelationID/AutopilotTraceUID 字段和 ChannelLogOption;BuildPlan 写入 dry-run trace(必落盘);NormalizeSelectionTrace 将 SelectionTrace 规范化为安全摘要并 AttachSchedulerDecision;AppendEndpointAttempt 有序/容量受限/乱序合并的 attempt 摘要\n- **endpoint attempt 摘要接入实际请求链路** - SetAttemptRecorderHook + recordAttemptCompleted hook 模式;upstream_failover 在 CreatePendingLog 后记 \"started\",成功/错误时记 \"completed\";main.go 注册 hook 连接 TraceStore;fail-open 观测失败不影响代理请求\n- **受控发布与自动降级** - active 模式加入配置枚举/归一化/IsAutopilotActive;RolloutPercent/ControlPercent/ReleaseID/RolloutSeed 配置字段和 validateRollout 校验;ReleaseController 集中管理状态迁移(逐级晋升/降级随时允许)、稳定分桶、EvaluateAndApplyRegression 三窗口回归降级、SafetyOverride 安全覆盖;接入 SmartRouter 请求路径(入口冻结 RoutingReleaseSnapshot);release/policy/cohort 隔离聚合\n- **只读 Trace API 与前端详情** - GET /api/autopilot/traces/:traceUid 详情端点(404/503/partial 契约、2s 查询 deadline);列表 API 改为 TraceSummary 输出,支持 release/cohort/mode 过滤和游标分页;统计新增三态比较计数;前端 AutopilotTraceDetailDialog 决策→Scheduler→尝试→终态时间线;AutopilotTraceTable 适配 TraceSummary;ChannelLogsDialog 跳转入口\n- **分层测试(L1-L5)** - L1 纯函数 40+;L2 SmartRouter+TraceStore 集成 8;L3 SQLite 生命周期 12(重启还原/坏 JSON/过期清理/迁移幂等/脱敏);L4 HTTP 契约 9(列表/详情/统计/404/脱敏/分页);L5 真实上游 smoke 2(默认 t.Skip);SSE golden 回归 6(帧顺序/trace 不改输出/取消/脱敏)\n- **Autopilot 模型与思考强度联合路由** - 自动路由的决策单元从「模型」提升为「模型 + 思考强度」的原子组合,解决模型被改写为异构上游后仍沿用客户端原档位、落在从未评测过的能力点上的问题。新增 `ResolvedRouteTarget` 作为贯穿全链路的唯一决策载体;`NormalizeEffortLevel` 统一证据侧/能力侧/请求侧的厂商档位命名,`IsUnpinnedEffort` 使 `default` 类未固定档位证据只能作降级 fallback 而非冒充精确匹配;`rankEligibleModels` 按 `SupportedEffortLevels × PerTaskClass` 展开 model×effort 候选,`betterRankedModel` 加入「够用即止」平手规则防档位膨胀,`filterEffortFloor` 按任务类下界过滤且全被过滤时 fail-open;`upstream_failover` 原子改写 model + 思考参数(任一步失败整体放弃),含 `adaptive_only` 守卫与 Gemini `thinkingConfig.thinkingLevel` 原生注入路径;`ManualRoutingIntent` 新增可选 `Effort` 字段(未知值校验拒绝,客户端显式 off/none 优先于意图锁定);`ChannelLog` 新增 `effortDecisionSource`/`effortClampedByClient`(仅在客户端上限实际生效时置位)与 Trace `effort` 维度;`PreferAutoOverManual` 开关支持手工映射优先级反转(默认关闭,可零代码回滚),新增 `GET /health-center/profile-coverage` 只读画像覆盖率诊断作为手工表退役前置门槛\n- **火山 Agent Plan AFP 成本感知路由** - 新增火山 AFP 政策目录与纯计算器、套餐作用域解析与可比较性判断、AFP 请求快照与候选级成本证据;AFP 折扣接入 SavingsScore 参与评分并写入 trace,compshare 单次减扣同样纳入;新增 `frontierRoutingEnabled` / `afpCostRoutingEnabled` 两个正交开关,AFP 成本路由默认开启\n- **确定性 Pareto 分层与三倾向候选阶梯** - 候选排序改为确定性 Pareto 分层,输出成本/质量/均衡三倾向阶梯;模型候选排序重排并接入 CostPreference\n- **渠道预检异步化与后台 discovery 断点续传** - 渠道预检不再阻塞添加流程,后台 discovery 支持断点续传\n- **多协议自动发现补全** - 补全多协议自动发现并自动启用已发现协议路由;自动发现改从 `apiKeyConfigs` 取密钥;协议探测改为逐模型验证;快速探活改为多模型候选策略(含回退测试);自定义协议渠道构造统一\n- **发现 RPM 应用与账号级 429 scoped cooldown** - 接线自动发现出的 RPM 到运行态限速,429 冷却收敛到账号级作用域\n- **连接耗时采集驱动 SpeedTier** - 新增连接建立耗时指标并参与速度分级\n- **弃用参数自动发现与记忆落盘** - 上游不支持的参数按渠道-Key-模型维度自动发现并剥离,记忆落盘后重启免重新探测\n- **渠道兼容性开关统一自学习** - 六项兼容性开关(`NormalizeNonstandardChatRoles`/`StripImageGenerationTool`/`CodexNativeToolPassthrough`/`StripEmptyTextBlocks`/`PassbackReasoningContent`/`PassbackThinkingBlocks`)从「用户手工勾选的静态布尔」彻底收归「运行时按渠道-Key-模型自动学习并持久化的能力事实」:字段已从 `UpstreamConfig`/`UpstreamUpdate` 整体删除,管理面板不再提供编辑入口,渠道更新接口不再接受手工写入,用户此后无需手工干预。新增 `ChannelCompatCache`(落盘 `.config/channel_compat.json`,TTL 24h,键 `channelUID:keyHash:model`)统一记忆六项兼容能力,最终生效值按 `学习结论 > 历史种子 > 静态默认` 合成。老用户配置文件里残留的历史手工值不代表可信的长期事实(可能当初就设错,也可能上游后来有了新情况),因此启动时 `migrateManualCompatSwitchesToSeeds` 从磁盘原始 JSON 读出历史值,一次性降级为渠道级、TTL 14 天的一次性低置信度种子(`CompatSeeds`):迁移当下不改变可感知行为,但学到真实结论后立即让位,过期后也不再参与判断;种子 TTL 与学习记忆 TTL 分开存储(14 天 vs 24 小时),避免混淆。该迁移按数组下标把原始 JSON 对到当前渠道,因此必须排在 `mergeManagedProviderAccounts` 之前——后者会把同 provider 的多账号渠道合并成一条(数组变短重排),顺序颠倒会让种子写到错误渠道或随被合并渠道丢失;`mergeManagedProviderAccounts` 同时补上了合并时携带 `CompatSeeds`,避免被合并掉的渠道的历史证据丢失。`Clone()` 一并深拷贝 `LearnedCompatTraits`,避免克隆已注入结论的 upstream 时共享 map。按信号强度分三条学习路径:有硬 400 信号的走 `CompatTraitFromError` 错误驱动(识别后同 Key 立即重试,改写在下一轮注入本次请求专属的 `LearnedCompatTraits`),其中 Codex `/v1/responses` 打到不支持 `developer` role 的上游时新增 `DowngradeDeveloperRoleToSystem` 窄改写(降 `system` 而非 `user`,保留开发者指令语义);仅能靠响应形态判断的 `passbackReasoningContent`/`stripEmptyTextBlocks` 复用渠道诊断探针,改为首次遇到该组合时 singleflight 后台自动探测一次,替代原先要用户手工点按钮;图片生成受限从「直接拉黑 (Key,模型)」改为「先学习剥离图片工具并重试,剥离后仍失败才回落拉黑」。所有识别规则采用双条件门控(正则命中 + 请求侧确实携带对应结构),且 429/5xx/超时一律不参与兼容性学习,避免把容量问题学成能力缺失。`RuntimeUpstreamForAutoManagedProvider` 连种子一并清空,自动托管渠道的兼容性完全由厂商原生默认加运行时学习决定\n- **Kimi 参数硬约束迁入 model-registry 刷新链路** - Kimi K3/K2.7-code/K2.6 的 temperature/top_p/n 固定值、tool_choice/thinking 约束不再是本地硬编码前缀表,改为 `UpstreamModelCapability.ParamConstraints`,随 `shared/model-registry/ccx_model_registry.json` 走 presetstore 的定期刷新链路发布;运营者核对厂商文档后更新 JSON 即可让线上实例在下一个轮询周期生效,不需要重新编译发版\n- **Kimi 按量模型参数硬约束前置规避** - `ApplyKnownParamConstraints` 按厂商文档在发送前规避已知约束,无需先失败一次:`kimi-k3`/`kimi-k2.7-code` 系列/`kimi-k2.6` 的 `temperature`/`top_p`/`n`/`presence_penalty`/`frequency_penalty` 为固定值,传入即 `invalid_request_error`,故直接剥离(上游用自身默认值,语义无损);`kimi-k2.6`/`kimi-k2.7-code` 不支持 `tool_choice: \"required\"`,降级为 `auto`;`kimi-k2.7-code` 的 `thinking` 仅接受 `{\"type\":\"enabled\",\"keep\":\"all\"}`,非法配置归一化为该值。`moonshot-v1` 系列的可改参数不受影响\n- **注册 Claude Opus 5** - 新增 Claude Opus 5 模型注册并支持 4.8 点号别名\n- **模型能力基准数据源扩展** - 接入 Artificial Analysis 数据源并纳入 opus-5;benchlm 改用官方 `models.json` 取代对比页抓取;benchmark-update 增加页面变更检测缓存层;补全 DEEPSWE/BENCHLM/DRADAR 白名单 CCX 模型映射与 kimi-k3、grok-4.5、muse-spark-1.1 等模型,DEEPSWE cohort 扩展至 17 模型;刷新 model registry 至 20260726\n- **trace 与账号信息可读性** - trace 候选渠道展示渠道名与 Key 掩码;账号列表返回脱敏 access token;`UsageMeter-Record` 日志补充渠道/接口/状态上下文\n- **auto 模式 SLO 回归保护可配置** - SLO 回归自动降级改为开关控制\n- **模型清单展示改进** - 共有/协议专属模型少于 10 个时直接展开列出,其余默认两行、仅实际溢出时显示展开\n- **火山套餐 Key 保活 per-key 路由与共享探针** - `config.BaseURLsForKey` 让已绑定端点的 Key 只在自己端点探测,不参与渠道级 BaseURL 笛卡尔积;新增 `internal/upstreamprobe` 共享火山 Agent/Coding Plan 数据面探针(autopilot 验证与 healthcheck 保活共用,避免请求特征漂移);`L1Response.RealCallVerified` 标记真实调用,火山 L1 成功后同周期跳过等价 L2 避免重复消耗额度;L2 探针副本覆盖绑定 BaseURL,recordFailure 归因到 Key 实际绑定端点\n- **渠道级实测上下文上限自学习** - 模型注册表登记的是模型公开窗口,个别渠道对某个模型的实际窗口更短(中转商自行截断、上游按套餐限制),长上下文请求会反复吃 400 `context_too_large`。新增按 渠道-Key-模型 维度记忆实测上限的数值型能力覆盖(`config.ContextLimitState`,复用 `ChannelCompatCache` 键空间/24h TTL/落盘,但独立于布尔型 `CompatTrait` 存储):`ContextLimitFromError` 从 400/422 报错识别超限信号,上游明确声明窗口值时直接采信,仅表示「太长」时按被拒请求量的 7/8 反推保守上界,并排除 `max_tokens`/请求体过大/图片尺寸/配额等相邻错误避免误判;合成规则「宁小勿大」多次学习取最小值,只在遇到更严格证据时收紧,放宽只能靠 TTL 过期重学;`SmartRouter.buildChannelEntry` 取 `min(注册表窗口, 实测上限)` 让上下文硬约束按真实容量判断,路由发生在选定 Key 之前故取该渠道-模型在所有已知 Key 上的最小值。上下文上限按自身 `LearnedAt` 独立计算 TTL(不共用 `entry.DetectedAt`,否则 trait 命中刷新会让实测上限被无限续期);无记忆时 fail-open,反推上界低于 4096 不采信。兼容性记忆单例上移为 `config.SharedChannelCompatCache`,供写入方 handlers 与读取方 autopilot 共享\n\n### 修复\n\n- **兼容性自学习块位于 failover 判断之后导致生产环境从未生效** - `upstream_failover.go` 的弃用参数自适应与渠道兼容性自学习(developer role 降级、Codex 工具剥离等)两个学习块原本写在 `if shouldFailover` 分支之后。而错误分类以「大多数非 2xx 都 failover」为默认策略,未命中已知不可重试关键词的 400/422 会被判定为 `shouldFailover=true` 并提前 `continue`,导致这两个学习块在默认配置下几乎从未被执行到——只有极少数恰好命中窄关键词表的报错才能落到它们。现已移至 `shouldFailover` 判断之前,与上下文上限自学习块位置一致;新增用真实 `TryUpstreamWithAllKeys` 驱动的集成测试钉住「上游 422 → 学习 → 同 Key 重试成功」完整闭环\n- **422 兼容性学习分支不可达** - 兼容性自学习块声称覆盖 400/422,但被嵌套在外层 `resp.StatusCode == 400` 守卫内,422 分支实际永远不可达(部分上游用 422 表达请求结构不受支持);改为与弃用参数块平级的兄弟块——弃用参数识别只针对 400,兼容性学习覆盖 400 与 422,二者条件不同不应嵌套\n- **Chat 入口 Gemini 上游响应被误判为空** - `handleSuccess` 的 `default` 分支承接 gemini 等没有专属 case 的透传上游类型,却统一使用只认识 OpenAI `choices[].message` 结构的 `IsChatResponseEmpty` 判空;对 Gemini 的 `candidates` 结构,`respMap[\"choices\"]` 断言必然失败而被判定为空,导致所有合法 Gemini 响应都误触发 failover、最终以 503 收尾。改为按 `candidates` 字段(Gemini 专属、非 omitempty)区分 schema 后走 `IsGeminiResponseEmpty`;不用 `usageMetadata` 判断是因为该字段为 omitempty,缺省时会误判回 OpenAI 形态\n- **effort 注入形态误判与三处死代码** - 自动托管渠道被 `RuntimeUpstreamForAutoManagedProvider` 清空 `ReasoningParamStyle` 后,`effortInjectionStyle` 一律回落 `reasoning` 对象形式,导致 Claude messages 渠道被写入 `reasoning.effort`、chat 渠道被写成对象,上游均不识别而静默丢弃 effort;改为按渠道类型推导原生形态(messages→`thinking`,chat→`reasoning_effort`)。同时修复三处「代码存在但调用链不通」的死代码:`CapabilityFloor.EffortFloor` 从未被赋值致 `filterEffortFloor` 恒直接返回,改为在 `rankEligibleModels` 内按 `ReasoningEffortConfig.PerTaskClass` 推导;`resolveRelativeBenchmarkEvidence` 的 `targetEffort` 恒传空串致 `(domain, effort)` 精确匹配与置信度折算永不执行,新增 `ResolveDomainStrengthForEffort` 并在候选展开处按变体实际档位取证据;`extractClientEffort` 协议无关全字段扫描与按渠道分支的 `ExtractClientEffortExplicit` 对跨协议字段判定相反,导致手动意图 effort 被静默屏蔽而日志记录未钳位,删除前者统一走后者。另补齐 messages 分支的 `thinking.effort` 提取(此前 `type=enabled` 只返回 `enabled` 占位而非真实档位)\n- **精确模型命中绕过 effort 决策** - `ResolveModel` 的精确/等价模型命中路径直接返回,从未调用 `resolveEffortVariants`,导致请求模型能在渠道内精确匹配时(最常见路径)手动意图锁定档位与自动展开全部失效,仅跨模型替换才走 effort 逻辑;抽出 `resolveSingleProfileEffort` 在两处分支补齐,语义与排序路径一致(意图锁定优先,否则按配置展开取最低档)\n- **effort 钳位语义与 Gemini 档位提取** - 修正 effort 钳位语义并在 failover 时重置标记;Gemini effort 提取取真实档位而非占位值,`targetByUID` 写入条件统一;`ResolvedRouteTarget` 定义与 `upstream_failover` 旧 API 保留兼容 fallback\n- **火山套餐 Key 识别与恢复** - 优先识别火山 Agent Plan 的 Kimi K3;按火山额度重置时间恢复被拉黑的 Key;`ListArkAgentPlanModel` 签名 scope 从 `ark_stg` 改回 `ark`\n- **渠道列表与诊断展示错位** - 渠道列表第二个渠道不再错显首个渠道的请求数与缓存率;渠道排序不再随 Key 拉黑变动;重新发现协议后同步刷新渠道列表;自定义渠道协议发现结果正常展示;路由过滤原因与诊断面板改用渠道名和热门模型替代 `ch_xxx` 内部 ID;Key 与健康状态同步修复\n- **驾驶舱空白** - 意图列表 nil slice 被序列化成 `null` 导致驾驶舱整页空白\n- **system 角色归一化破坏缓存边界** - providers 归一化 system 角色时保留 `cache_control` block 边界,避免打断上游 prompt 缓存\n- **billing header 域名判断忽略端口号** - 带端口的自建地址不再被误判为非官方域名,新建渠道默认值跟随 baseUrl\n- **保活验证失败无痕迹** - healthcheck 保活验证失败写入渠道日志\n- **连接延迟慢速判定误报** - 样本数不足 20 时 P95 退化为最大值导致误判慢速,新增最小样本门槛\n- **new-api 地址与图标注册** - new-api 地址自动剥离控制台路径;补齐 `mdi-github`/`mdi-plus-circle`/`mdiPause`/`timer-alert-outline`/信息提示轮廓等缺失图标注册;简化延迟告警芯片文案\n- **基准与预置数据修正** - litellm 映射补齐 26 个模型定价并去重 Kimi 会员条目;AA evidence 补 `taskCount` 修复 TS 类型校验;presetstore 支持多量纲基准原始值;区分 kimi-k3 的 256k/1m 变体上下文;清理渠道配置中过时的 grok 精确模型映射\n- **RoutingReleaseSnapshot 字段缺失** - 补全 `RolloutSeed`/`ControlPercent`,避免灰度分桶快照失真\n- **火山套餐 Key 跨套餐误探测** - 混合套餐渠道中 Agent Plan Key 不再被首地址 `/api/coding` 误打导致 auth_failed 误拉黑;火山官方套餐入口不再调用通用 `/v1/models`(套餐 Key 无法通过该接口探测),改用专用探针 + 内置 manifest 模型清单;400/404/500/网络错误不升级为认证失败\n- **渠道全部 Key 禁用时无恢复入口** - `hasOnlyDisabledChannelApiKeys` 判定(可用=0 且禁用>0)下渠道行直接显示恢复按钮,一次点击恢复全部禁用 Key,无需先暂停;active 渠道跳过冗余 `setStatus(active)`,直接 resume 并刷新\n- **非官方域名默认剔除 billing header** - `stripBillingHeader` 未显式设置时按渠道 BaseURL 推断默认值:仅当渠道所有地址均为 Anthropic 官方 API(`api.anthropic.com`)时保留计费 header,其余第三方渠道默认剔除;此前默认关闭,导致 Claude Code 每次请求变化的 `cch=` 计费 nonce 打穿第三方上游的 prompt 缓存前缀,造成缓存命中率异常下降\n\n### 重构\n\n- **移除 Fuzzy 模式开关,统一为单一 failover 策略(BREAKING)** - Fuzzy 模式早已是唯一实际使用的默认值,Normal 模式的精确状态码分类分支已无存在意义,保留两套逻辑徒增维护成本。删除 `FuzzyModeEnabled` 配置字段、`GetFuzzyModeEnabled`/`SetFuzzyModeEnabled`、`GET/PUT /api/settings/fuzzy-mode` 接口及其配置迁移逻辑;`shouldRetryWithNextKeyNormal` 与 `classifyByStatusCode` 整体删除,`shouldRetryWithNextKeyFuzzy` 重命名为 `shouldRetryWithNextKey` 成为唯一实现;`HandleAllChannelsFailed`/`HandleAllKeysFailed` 不再接收 `fuzzyMode` 参数,统一返回通用 503;各协议 `handleSuccess` 的空响应拦截(`IsClaudeResponseEmpty` 等)不再受 `fuzzyMode` 门控而无条件生效;Web 与桌面前端一并移除工具栏开关、stores 状态、API 封装与 i18n key。**行为变化**:404/405/409-417/422/423/424/426/428/431/451 与 3xx 不再走「不 failover 直接透传上游原始状态码与错误体」路径,这类错误会消耗全部 Key/渠道后以通用 503 收尾;全部渠道失败时不再透传最后一个上游错误详情(`model_not_found` 等可归一化的模型路由错误仍保留精确状态码透传)\n- **订阅中心精简** - 移除分区标题、能力标签与手动添加入口,补齐快捷接入文案;赞助商置顶、new-api 置底,GitHub Copilot 卡移到倒数第二位\n- **移除渠道菜单的试用意图入口**\n- **清理火山套餐历史分支** - 移除 `volcenginePlanModelsHost` 常量与 `endpointFor` 的 `ark_stg` 分支\n\n### 优化\n\n- **展示格式统一** - 统一所有订阅套餐余量展示格式并补全火山\"剩余\"前缀;组合展示统一为单空格分隔;渠道日志不再展示调度摘要原始串\n- **后端代码质量** - 清理 Go lint 问题,gofmt 对齐结构体字段与注释缩进\n\n### 测试\n\n- **Autopilot 分层测试补齐** - L2 SmartRouter + TraceStore 集成、L3 SQLite 生命周期、L4 HTTP 契约、L5 真实上游 smoke 与 SSE golden 回归\n- **回归与稳定性** - 快速探活多模型候选回退测试;healthcheck `per_key_test` flaky 记录断言修复\n- **基准断言同步** - 同步模型能力基准断言,且不再硬编码 registry 刷新日期与分值\n\n### 文档\n\n- **Autopilot 计划与设计** - 新增 trace 灰度实施计划、火山 AFP 感知路由计划、火山套餐 Key 保活恢复计划、渠道异步发现方案,并补充 model+effort 联合决策现状与进度\n- **配置与基准说明** - 火山 provider 模板补充 AFP 路由说明;`make help` 补充 `ARTIFICIAL_ANALYSIS_API_KEY` 用法;量化 Kimi HighSpeed 配额消耗为 3 倍;修正 `canonicalModelToPattern` 的 kimi 分支注释\n\n### 其他\n\n- **停止跟踪 upstream-check 运行时状态文件**\n- **重新生成 model registry 产物**\n\n## [v3.0.0] - 2026-07-23\n\n### 新增\n\n- **Claude Code System Header 过滤** - OpenAI/Gemini/Responses provider 自动过滤 CC billing header、identity 与 subagent 角色描述,减少上游噪音;StripBillingHeader 扩展为完整移除 billing block\n- **分层 system header 过滤接入请求流程** - Messages 入口按 channel-keyHash-model 记忆最优过滤层级,连接错误与 HTTP failover 失败时渐进升级,成功后巩固层级;仅影响 Claude 透传入口,其余 provider 保持原有过滤\n- **托管渠道模型清单重新发现** - 编辑渠道对话框为托管渠道提供重新发现按钮,触发 auto-discover 后轮询任务状态并自动刷新模型清单,支持 zh-CN/en/id 三语\n- **Kimi Code 套餐用量查询** - Kimi 自动托管凭证支持绑定本地 Web 会话令牌,查询并刷新周额度、短期频限、订阅与赠送余额;绑定、刷新与账号列表接口均不回显令牌\n- **DeepSeek 官方账号自动托管** - 快速添加同时创建 Messages、Chat 与 Responses 协议渠道,模型发现统一使用官方 `/models`,编辑渠道时可按凭证查询余额\n- **DeepSeek 峰谷成本倍率配置** - Autopilot 成本评分支持按供应商、时区和时间窗口应用倍率;内置规则延后至 2026-07-20 激活,可通过配置调整生效时间\n- **Autopilot 规范模型能力基准** - 模型注册表新增独立 benchmark 领域向量,按渠道质量证据向下折算并写入路由 trace;首批覆盖 Claude Opus 4.8、GPT-5.6 Terra 与 GPT-5.6 Sol\n- **Autopilot 手动 L3 供应商质量探测** - 新增固定 canary、每日预算与 endpoint×model 画像回写,支持 Messages、Chat、Responses、Gemini 上游协议,响应和持久化均不包含明文 Key 或模型原始输出\n- **new-api 多账号管理** - 支持为已有 new-api 订阅添加多个账号(accessToken),后端提供账号 CRUD API,渠道编辑页面可管理账号列表\n- **Autopilot 智能路由引擎(Phase 1-4 全部完成)** - SmartRouter 评分引擎(§5.3 九项公式 + §5.5 派系偏好 + §5.6 价格偏向)、确定性 TaskClassifier + 路由配置/kill switch、shadow 注入与 assist/auto 模式语义转正、EndpointAttemptPolicy(§4.6.2)+ TryUpstreamWithAllKeys、CapabilityFloor + MinQualityTier reasons、enforce model routing policy boundaries、接入模型注册表能力下界与上下文窗口硬约束、Phase 1 收尾接线、Phase 2 剩余5项集成、P1.5 迁移与回滚契约\n- **Autopilot 画像与健康诊断系统** - ProfileStore(内存缓存 + SQLite 异步持久化)、Profiler L1 被动画像推导、HealthAnalyzer L1 被动健康诊断、后台聚合 worker、健康中心只读 API 与前端视图、LocalModelRuntimeProfile 本地运行时画像与探测、LocalCandidateProvider 进入 endpoint 候选池\n- **Autopilot TrustedRoutingAdvisor 与决策追踪** - 五态状态机 + AdvisorHintEffect 路由约束解析、shadow 评估 + 决策记录 + API、画像变更事件推送与前端时间线(Phase 3A)、RoutingDecisionTrace + TraceStore + trace handlers、智能路由诊断面板与自动模型映射诊断预览\n- **Autopilot 时序与限流感知** - TimeBucketMetrics + QualityTrend 检测(§3.10)、RateLimitDiscoverer shadow(§4.5)、GroupChangeDetector 分组变更检测(§3.9)、FastDecay shadow 评分 + 批量查询 + RPM 运行态注入、接线限流信号/时序/分组变更到 Manager 与健康中心 API\n- **Autopilot L2 探测与模型映射** - L2 轻量探测 worker + 每日预算 + 探测队列、模型自动映射闭环(Phase 3B-2)、StabilityTier 晋降级滞后(Phase 3B-3)、L2 探测自愈闭环(Phase 3B-1)、自动发现完成后一致结果自动写入 SupportedModels\n- **Autopilot 订阅与用量画像** - SubscriptionProfile + 驾驶舱聚合 API + 三 store 接线、§3.2.3 订阅级能力共享 + §3.2.4 用量窗口 Phase 1 shadow、订阅中心 provider adapter(余额自动刷新)\n- **Autopilot 人工路由意图** - ManualRoutingIntent(Phase 1 shadow)、参与 SmartRouter 路由(§4.6.4 人工意图执行语义)\n- **Autopilot 真实请求画像与自适应超时** - 基于真实请求画像、TTFB 自适应响应头超时、采集端点响应头延迟画像、跨重启保留 TTFB 画像\n- **Autopilot auto 上线闸门与自动降级** - auto 上线闸门、自动降级、终态渠道降至 assist 队尾\n- **Autopilot A/B 测试** - 低比例统计抽样双发、ABTestConfig.Validate() 补齐负值/越界兜底\n- **Autopilot SLO regression 自动回滚**\n- **Autopilot 本地任务模板系统**\n- **Autopilot 用户自定义标签系统** (Phase 4 Item 1)\n- **Autopilot 成本报表** - 按用户/模型/渠道/key (Phase 4 Item 2)\n- **Autopilot 渠道推荐** (Phase 4 Item 4)\n- **Autopilot 批量渠道管理** - 导入/导出/模板 (Phase 4 Item 5)\n- **Autopilot new-api 订阅集成** - 后端 adapter + 前端\n- **Autopilot 火山方舟套餐用量查询与展示** - 未绑定 Access Key 时回退内置兜底模型清单,兜底模型清单区分 Agent Plan 与 Coding Plan\n- **Autopilot MiniMax Token Plan 用量查询** - MiMo 绑定控制台 Cookie 查询 Token Plan\n- **渠道自动探测与聚合** - 自动探测并聚合多协议渠道、在编辑页展示协议可用模型、自动识别发现协议类型与展示自动探测协议地址\n- **智谱官方渠道自动识别** - 自动识别智谱官方渠道模板,补齐桌面端渠道自动识别模板\n- **快速添加渠道 Provider 模板化** - 选 provider + 输 key 即可创建、per-key baseURL 探测与绑定\n- **多协议渠道统一展示** - 统一展示多协议渠道、按上游协议展示渠道标签、稳定多协议渠道排序\n- **渠道保活验证模块** - 新增渠道保活验证、渠道健康检查配置与持久化\n- **(Key,模型) 组合级限制与定时自动恢复** - failover 新增组合级限制,定时自动恢复\n- **内置模型清单增强** - /v1/models 补充 max_output_tokens (#266)、同步 LiteLLM 能力与 DeepSWE 证据\n- **模型能力基准数据自动更新** - benchmark 自动更新模型能力基准数据\n- **优云智算套餐余量查询** - compshare 绑定优云智算套餐余量查询、同步套餐并发限速\n- **Kimi Code 模型与按 Key 发现**\n- **火山引擎 Agent 与 Coding Plan 自动识别**\n- **自动托管账号持久化层级** - 账号级凭证接口、切换凭证差异更新、聚合旧版托管协议渠道\n- **new-api 安全分组倍率路由**\n- **远程预置更新运行时** - 预置数据集中管理 + 运行时可更新架构(Phase 1)、完成远程预置更新运行时\n- **订阅来源等级只读展示** - 来源等级由来源类型系统推导,改为只读展示\n- **火山引擎与 BytePlus 赞助**\n- **套餐类渠道 Key 行余量摘要补齐多时间窗**\n- **桌面端 autopilot 对齐** - 管理面板+健康中心+骨架(第一波)、cockpit概览+new-api订阅+试用意图(第二波)\n- **桌面端渠道自动探测与复用同步** - 同步渠道自动探测与复用、托管渠道识别模板与徽章、Codex 生图工具权限探测、TTFB 首字节 P95 列、auto 上线闸门与自动降级到桌面端健康中心\n- **Responses 音频 content block 丢弃告警** - converters: Responses 音频 content block 丢弃时输出告警日志\n- **前端 Health Center / Subscriptions / Cockpit 视图**\n- **渠道卡片健康 badge + 来源标签系统(§8.2)**\n\n### 修复\n\n- **本地部署渠道重新发现后显示\"来源未知/历史记录未保存\"** - `writeProfiles` 与 `buildEndpointInventory` 统一用 `CanonicalBaseURL` 规范化 baseURL,修复带 `/v1` 后缀时 endpointUID 不一致导致画像写入后无法被 `ListActiveByChannel` 命中的问题\n- **套餐型 Provider Key 限额重置后不自动恢复** - Kimi/MiMo/优云智算/火山用量刷新后新增 `TryRestoreDisabledKeysByUsage`,按各 Provider 用量快照的重置时间与剩余额度自动恢复因余额/限额不足被拉黑的 Key;同时修复 `syncManagedAccountsFromChannels` 仅从活跃 Key 重建凭证池、导致被拉黑托管 Key 的 Console token/AccessKey/用量快照在下次配置保存时丢失的问题\n- **Provider 多协议账号部分写入** - 首次创建与旧账号补协议改为验证后单次提交,失败不残留半成品渠道,并保留 MiMo 区域及火山套餐端点亲和性\n- **快速添加成功后对话框未关闭** - provider 自动托管创建成功后立即结束交互,模型发现继续在后台执行\n- **Autopilot 配置迁移与运行态隔离** - 旧配置块解析或升级失败时回退安全默认值,支持结构体指针叠加,并避免环境急停经管理 API 写回配置文件\n- **辅助路由协议回归** - `assist` 保留无法评分的原候选供 failover,真实路由 trace 回填实际渠道,修复模型不支持错误重试、普通 Key 的模型限制执行、relay 耗尽误限制及 Messages 响应模型字段丢失\n- **new-api 验证失败** - 修复某些 new-api fork(如 Veloera/voapi)要求 `New-API-User` header 导致的 401 错误,新增 VerifyWithFallback 两阶段验证回退逻辑\n- **套餐 Key 恢复边界条件收紧** - P1 收紧恢复条件为 AND 语义、P2 修复 Kimi/火山套餐恢复边界条件\n- **system header 过滤稳定性修复** - 修复 panic、过期误判与误升级问题、字符串形式 system 未过滤 CC header 的问题\n- **Autopilot 替代模型选择优化** - 按任务难度选择替代模型、优先选择更强同档模型、使用渠道模型倍率排序、按能力下限优化排序、避免常规任务过度升级模型、修正多模态模型能力路由\n- **Autopilot 模型发现与画像修复** - 记录模型发现来源与时间、自适应模型解析修正、endpoint metrics identity 对齐、profile model support decisions 遵守、当前有效画像清单对账\n- **Autopilot 路由约束对齐** - scheduler diagnose 与 request profile 对齐、shadow 与自动路由约束对齐、dry-run 与自动路由决策对齐、诊断画像与路由追踪统一\n- **Autopilot 配置迁移与隔离修复** - 自动升级旧版 Autopilot 配置、配置迁移隔离完善、环境急停经管理 API 写回配置文件隔离\n- **火山方舟 Agent Plan 修复** - 探针与协议入口区分、Key 端点验证修复、套餐 Key 验证请求特征对齐、Coding Plan 用量刷新修复、套餐模型发现签名修复\n- **渠道展示与编辑修复** - 托管渠道真实模型范围展示、额度禁用状态与用量展示修正、渠道行展示与官方渠道命名/官网编辑优化、渠道添加与官方渠道编辑流程简化\n- **自动托管渠道编辑精简** - 多轮精简自动托管渠道编辑项、收敛编辑项\n- **Autopilot 端到端流程修复** - auto-add/auto-status 端到端流程、辅助路由协议回归、终态渠道降至 assist 队尾\n- **MiMo 自动发现修复** - 自动发现状态修复、自动发现失败修复、自动添加多协议渠道\n- **桌面端同步修复** - 额度禁用状态与 Key 计数、baseUrl 清理路径、自动渠道命名简化\n- **前端图标注册修复** - mdi-check-all、DeepSeek 余额图标、mdi-connection、ApiKeyManagementSection 缺失 mdi 图标、官方渠道标识图标\n- **前端 TypeScript 编译错误修复**\n- **Compshare system roles 规范化** - messages: normalize Compshare system roles\n- **LLM 统一渠道视图置顶/置底排序修复**\n- **模型定价同步与未配置成本标记**\n- **重新发现轮询识别 failed 状态并展示错误**\n- **拉取渠道模型列表时使用 key 级绑定 baseUrl**\n- **模型能力基准多来源更新与图表生成修复**\n- **熔断引入模型多样性门槛与渠道组合失败熔断**\n- **自动托管渠道官网地址持久化**\n- **快速添加渠道名称预览修复**\n- **最终上游模型日志记录** - logging: record final upstream model\n- **内部元数据泄漏到续轮请求阻止** - responses: 阻止内部元数据泄漏到续轮请求\n- **快速添加前去重 API Key**\n- **渠道自动命名简化与仪表盘轮询合并**\n- **容器后端验证的前端嵌入修复**\n- **baseUrl 清理剔除 /api-keys 创建页路径**\n- **disabled managed credentials 跳过** - routing: skip disabled managed credentials\n- **Claude Code system 指纹与客户端探针特征对齐**\n- **小屏协议地址布局优化与协议名称换行避免**\n- **添加弹窗正文滚动与 usage 控制台路径识别**\n- **Autopilot 按真实协议创建自定义渠道**\n- **Autopilot DeepSeek 官方账号托管完善**\n- **逻辑渠道多协议日志汇总**\n- **按客户端协议持久化画像分类与发现完成后立即持久化**\n- **绑定模型发现结果保留**\n- **官方 provider 凭证池复用与自动托管官方渠道身份标明**\n- **按 endpoint binding 隔离模型调度** - routing: 按 endpoint binding 隔离模型调度\n- **旧版托管协议渠道聚合**\n- **官方渠道改用智能调度**\n- **快速添加成功后关闭弹窗**\n- **content_part 流式事件识别** - responses: 识别 content_part 流式事件\n- **schema 迁移幂等自愈 advisor_decisions.reason 列**\n- **scoreEndpointForKey EndpointUID 与 handlers 层不一致修复**\n- **RateLimitApplier kill switch 测试闭包捕获值拷贝问题**\n- **Responses input item additional_tools 剥离**\n- **未知健康状态徽标颜色调整**\n- **快速添加上游类型选择移除**\n- **黑名单密钥恢复状态提示**\n\n### 重构\n\n- **订阅中心重构为 Provider 卡片式快捷接入面板** - 移除订阅列表表格展示,改为双栏布局:左侧 Provider 卡片(GitHub Copilot/new-api/手动添加),右侧详情表单;订阅中心只负责接入新订阅\n- **Agent skills 迁移至 .claude/skills** - 通过 symlink 将 agent skills 迁移到 .claude/skills 目录\n- **协议模型可用性 diff 视图重新设计** - frontend: 重新设计协议模型可用性差异视图\n- **Kimi 用量展示简化** - frontend: 简化 Kimi 用量展示以匹配官方 CLI\n\n### 优化\n\n- **路由性能优化** - config: clone only selected upstream;routing: cache request channel snapshots、prefilter incapable managed channels、prefilter channels without usable keys\n\n### 文档\n\n- **Autopilot 设计文档** - 新增同订阅多 Key 能力共享/配额负载均衡(§3.2.3)与用量窗口(§3.2.4)设计、任务域优势矩阵(§5.7)与思考等级差异(§5.8)设计、内置模型清单设计(§4.2.1)、new-api 集成与订阅 Provider Adapter 框架、Phase 3A/3B/4 全部完成状态更新\n- **桌面端文档** - 补充后端发现与端口附着约定\n- **Codex 上游协议变更评估** - 追加 Codex rust-v0.145.0 上游协议变更评估、新增 gpt-5.6 适配与 codex imagegen 上游文生图待办\n- **SignPath 代码签名致谢** - 文档站首页与 README 添加 SignPath 代码签名致谢,logo 放大并链接到官网\n- **Star History 图表更新** - 调整为 Markdown 图片并更新 token\n- **火山引擎推广链接** - README 为火山引擎注册即领 2500 万 Tokens 添加推广链接\n\n### 其他\n\n- **Windows 签名切换至正式证书策略** - ci: 切换 Windows 签名至正式证书策略\n- **Serena 项目配置更新**\n- **Autopilot 日志降噪** - 降低画像落盘成功日志噪声、移除画像推导冗余日志\n- **Codex 0.143.0 upstream check 记录**\n- **Apple Container 隔离验证** - 添加 Apple Container 隔离验证环境\n- **下载按钮改为两行网格布局** - style: 下载按钮改为两行网格布局\n\n## [v2.9.37] - 2026-07-07\n\n### 新增\n\n- **渠道选择 Trace 全链路诊断** - scheduler: 新增 channel selection trace,覆盖候选过滤、活跃模型过滤、选择失败原因,支持 `/v1/models/diagnose` 干跑视图与流式日志摘要\n- **响应续思折叠** - responses: 上游截断的 reasoning continuation 在代理层自动折叠,避免客户端收到不完整思考链\n- **渠道配置发现增强** - discovery: 增强渠道配置发现能力,改进推荐算法\n- **瞬态渠道发现** - discovery: 新增瞬态渠道发现机制,支持快速探测上游可用性\n- **断路器温和策略改为新默认** - circuit-breaker: 调整断路器策略预设,温和策略取代激进策略作为新默认值\n- **Agnes 模型描述注册** - registry: 为 Agnes 系列模型补充描述信息\n- **store-update 完成后自动打开下载目录** - skill: store-update 下载完成后自动在文件管理器中打开目标目录\n\n### 修复\n\n- **discovery 工具调用探测协议选择错误** - discovery: messages 渠道 serviceType 为 responses/chat/gemini 时,工具调用能力探测始终用 Claude 格式导致假阴性;修复为按 serviceType 自动选择 responses/chat/gemini 对应格式\n- **discovery 不再生成 supportedModels 通配白名单** - discovery: 发现流程自动写入 \\*alias\\* 通配符模式(\\*opus\\*、\\*gpt\\* 等)到 supportedModels,实际上渠道无需此限制;删除生成逻辑,apply 时始终清空旧值\n- **discovery 协议探测失败时模型映射为空** - discovery: 用户显式选定 channelKind 但该协议探测失败时,buildDiscoveryMappingRecommendation 返回空映射;降级到任意成功协议的模型重新构建别名映射\n- **thinking probe 误覆写 passbackReasoningContent** - compat: 历史 thinking block 探测返回 400/422 时错误地将 passbackReasoningContent 置为 false;此标志由独立 thinking 预算探测决定,不受历史块探测影响\n- **协议延迟统计含超时模型** - discovery: runDiscoveryProtocolProbe 对所有模型(含超时)的延迟做均值,导致有效延迟数据被拉高;改为仅统计成功模型均值\n- **gemini 渠道 reasoningMapping 暂不生成** - discovery: Gemini handler 目前未消费 reasoningMapping,写入配置只产生无效噪声;明确不为 gemini 渠道生成该字段,待 Gemini thinking 路径支持后再启用\n- **compat evidence 迭代顺序不稳定** - discovery: map 迭代顺序不确定导致证据列表每次不同;改为先排序 key 再遍历\n- **熔断器未将 no_available_account 计为过载** - failover: 上游返回 no_available_account 未触发熔断,新增 FailureClassOverloaded 分类\n- **serviceType 派生协议回退优先** - discovery: 当用户未显式指定 channelKind 时,优先使用 serviceType 派生的协议作为回退,而非仅依赖探测成功模型\n- **回退成功模型未传播到后续探测** - discovery: 首次协议探测成功得到的模型在后续探测中被忽略,导致其他协议探测缺少候选;改为将回退成功模型注入所有后续探测\n- **目标协议失败时未从回退协议构建映射** - discovery: 目标协议探测完全失败时 buildDiscoveryMappingRecommendation 返回空;改为从任意成功协议的模型重新构建别名映射\n- **发现流程白名单生成污染 supportedModels** - discovery: 自动写入通配符模式到 supportedModels 实际上限制了渠道可见模型;删除生成逻辑,apply 时始终清空旧值\n- **messages 渠道工具调用探测格式错误** - discovery: 工具调用能力探测始终用 Claude 格式;修复为按 serviceType 自动选择 responses/chat/gemini 对应格式\n- **显式渠道协议推荐被覆盖** - discovery: 用户明确指定 channelKind 时协议推荐仍被探测结果覆盖;改为优先保留用户选择\n- **malformed 响应未触发重试** - failover: 上游返回 malformed 响应体时未触发重试机制;新增重试逻辑\n- **Vue SFC 模块类型声明缺失** - desktop: 前端 typecheck 无法识别 .vue 文件模块声明;补充 env.d.ts 中的 SFC 模块声明\n- **桌面端开发模式未加载嵌入前端资源** - desktop: Wails dev 模式未正确加载 embed.FS 中的前端资源\n- **Wails 开发服务器日志过于冗杂** - desktop: 抑制 Wails dev server 信息级别日志输出\n- **.vue 导入 TS2307 类型错误** - frontend: tsconfig 未包含 env.d.ts 导致 .vue 文件导入报 TS2307\n- **模型列表未暴露上下文窗口** - models: /v1/models 返回结果未包含 context window 信息\n- **模型聚合等待窗口过短** - discovery: 多上游模型聚合的等待窗口不够长,部分慢响应渠道被遗漏\n- **discovery 重复创建 channel models fetcher** - discovery: 每次发现流程重新创建 fetcher 实例,改为复用已有实例\n- **上游临时过载未触发熔断** - backend: 上游返回 503/429 等临时过载状态码时未正确触发断路器\n- **未配置模型时 /v1/models 返回空** - models: 当模型未在上游注册时,回退到渠道配置的模型列表\n- **discovery 通道推荐 UI 对齐** - discovery: 通道推荐 UI 展示对齐设计稿\n- **上下文能力菜单被遮挡** - frontend: 上下文能力下拉菜单被其他元素遮挡\n- **渠道 URL 包含 profile wallet 路径** - frontend: 前端展示渠道 URL 时未剥离 profile wallet 路径前缀\n- **渠道 URL 包含 admin 路径** - frontend: 前端展示渠道 URL 时未剥离 /admin 路径前缀\n- **桌面端上下文模型下拉层级错误** - desktop: 上下文模型下拉菜单被高级选项遮挡,提升层级\n\n### 优化\n\n- **对齐 model registry map keys 格式** - style: 统一模型注册表 map keys 对齐方式,移除测试文件多余空行\n\n### 文档\n\n- **Channel Autopilot 设计文档** - docs: 新增 Channel Autopilot 自动路由设计文档\n- **Channel Autopilot 端点级粒度设计** - docs: 细化 Channel Autopilot 到端点级别的路由粒度设计\n- **Channel Autopilot 时间质量感知** - docs: 为 Channel Autopilot 设计补充时间维度质量感知机制\n- **Channel Autopilot 手动路由意图** - docs: 新增手动路由意图设计,支持用户显式指定路由偏好\n- **Channel Autopilot 成本感知路由** - docs: 新增成本感知路由设计,在质量与成本间自动权衡\n- **Channel Autopilot 原生 image/vector 路由** - docs: 将原生 image 与 vector 渠道纳入自动路由范围\n- **可信本地路由顾问** - docs: 新增可信本地路由顾问设计,基于历史数据提供路由建议\n- **用户价格偏向 (Cost Preference) 设计** - docs: 新增用户价格偏向设计,支持按成本偏好调整路由权重\n- **上游供应商质量差异设计 (ProviderQualityScore)** - docs: 补充上游供应商质量差异评分设计\n- **模型派系偏好 (Model Family Preference) 设计** - docs: 新增模型派系偏好设计,支持用户指定偏好的模型家族\n- **ModelFamily 枚举扩展至 17 个派系** - docs: 模型派系枚举扩展至 17 个,来自模型注册表\n- **Channel Autopilot 验收契约** - docs: 新增 Channel Autopilot 验收测试契约文档\n\n### 改进\n\n- **保留用户显式选定的渠道协议推荐** - discovery: 用户明确指定 channelKind 时优先保留别名,不受协议探测结果覆盖\n\n## [v2.9.36] - 2026-07-05\n\n### 新增\n\n- **集成 vectors Embedding 兼容性面板到桌面端渠道编辑流程** - desktop: 集成 vectors Embedding 兼容性面板到渠道编辑流程\n- **新增 vectors Embedding 兼容性配置面板组件** - desktop: 新增 vectors Embedding 兼容性配置面板组件\n- **移植 embedding 兼容性类型/序列化/i18n 到桌面端** - desktop: 移植 embedding 兼容性类型、序列化与 i18n 到桌面端\n- **OpenAI 响应思考标签提取为原生 reasoning 字段** - provider: OpenAI 响应思考标签提取为原生 reasoning 字段\n\n### 修复\n\n- **对齐桌面端 capability 测试反馈** - desktop: 对齐桌面端 capability 测试反馈\n- **使用结构化对话轮次** - cockpit: 使用结构化对话轮次替代纯文本\n- **embedding 兼容性配置边界问题** - vectors: 修复 embedding 兼容性配置边界问题\n- **剥离 embedding 请求体中的 stream 字段** - vectors: 剥离 embedding 请求体中的 stream 字段避免上游 SSE 误触发\n\n### 优化\n\n- **提取 buildEndpointURL 正则为 package-level var** - vectors: 提取 buildEndpointURL 正则为 package-level var 避免重复编译\n\n### 其他\n\n- **补齐 embedding capability 与 handler 边界测试** - test: 补齐 embedding capability 与 handler 边界测试\n\n## [v2.9.35] - 2026-07-04\n\n### 新增\n\n- **展开态当前渠道点击支持固定与取消固定切换** - cockpit: 展开态当前渠道点击支持固定与取消固定切换\n- **快捷渠道覆盖支持固定与取消固定** - cockpit: 快捷渠道覆盖支持固定与取消固定\n- **发布成功后自动调用 store-update** - github-release: 发布成功后自动调用 store-update\n\n### 修复\n\n- **向量化渠道兼容性配置边界问题** - vectors: 修复桌面端 Embedding 兼容性面板未指定 normalized 时崩溃、Vectors 渠道重名更新、非法 embeddingCapabilities 保存与非法 input 本地校验缺口\n- **拦截 Claude Code 模型切换探针避免空响应误判** - messages: 拦截 Claude Code 模型切换探针避免空响应误判\n- **修复 copilot 渠道 /v1/models 代理返回 404** - models: 修复 copilot 渠道 /v1/models 代理返回 404\n- **修正 gitignore 规则并添加 git 代理自动检测** - store-update: 修正 gitignore 规则并添加 git 代理自动检测\n\n### 其他\n\n- **同步 skill 更新到 .agents/skills/** - chore: 同步 skill 更新到 .agents/skills/\n\n## [v2.9.34] - 2026-07-04\n\n### 新增\n\n- **注册 mdiCash 图标** - ui: 注册 mdiCash 图标,补充前端图标资源\n- **5xx + Retry-After 也触发限速 cooldown** - ratelimit: 上游返回 5xx 或 Retry-After 头时同样触发限速 cooldown\n- **上游临时过载检测与渠道冷却机制** - failover: 新增上游临时过载检测与渠道冷却机制,提升故障转移鲁棒性\n\n### 修复\n\n- **单模型渠道 CostUSD 不显示** - metrics: 修复单模型渠道 CostUSD 不显示的问题\n- **限速窗口单位语义修正** - ratelimit: 修正限速窗口单位语义,字段值直接以秒为单位\n\n### 其他\n\n- **Serena 本地记忆目录加入忽略列表** - chore: 将 Serena 本地记忆目录加入 gitignore\n\n## [v2.9.33] - 2026-07-04\n\n### 修复\n\n- **DeepSeek context_management 不兼容导致 400** - providers: 过滤 DeepSeek Claude 兼容端点不支持的 `context_management.clear_thinking_20251015` 编辑和 `context-management-2025-06-27` beta header,修复 Claude Code 首轮/recap 请求的 thinking 回传 400\n\n## [v2.9.32] - 2026-07-03\n\n### 修复\n\n- **ChannelManager canDeleteChannel 引用错误** - desktop: 修复 ChannelManager canDeleteChannel 引用错误\n\n## [v2.9.31] - 2026-07-03\n\n### 新增\n\n- **DeepSeek tool_use passback 双重降级缓存** - thinking: DeepSeek tool_use passback 双重降级缓存\n\n### 修复\n\n- **opaque encrypted_reasoning 占位保障 DeepSeek thinking passback** - responses: opaque encrypted_reasoning 占位保障 DeepSeek thinking passback\n\n## [v2.9.30] - 2026-07-03\n\n### 新增\n\n- **原生 WebSocket v2 透传** - responses: 支持原生 WebSocket v2 透传\n- **流式 session 回写与 incomplete 合成** - responses: 流式路径 session 回写并在 post-commit stall 时合成 incomplete\n- **Responses reasoning encrypted_content 缓存** - thinkingcache: 扩展存储 Responses reasoning encrypted_content\n- **分层保真 compact** - compact: 增加分层保真 compact 与 function_call_output 摘要\n\n### 修复\n\n- **快速创建渠道数据源提交** - desktop: 统一快速创建渠道时提交数据源\n- **视觉回退上下文校验** - failover: 校验 vision fallback context,避免无效视觉回退上下文继续传播\n- **agent skills 路径与 Store MSIX tag 下载** - skill: 修复 `.agents/skills` 内 skill 文档误指向 `.claude/skills` 的路径,并确保 store-update 脚本的 `--tag` 会传递给 `gh release download`\n\n### 其他\n\n- **desktop-store-update 技能清理** - skill: 删除 desktop-store-update 技能并更新工具配置\n- **agent skills 同步** - skill: 同步 agent skills 文档、脚本和 upstream-check 状态\n\n## [v2.9.29] - 2026-07-03\n\n### 新增\n\n- **token 成本(USD) 统计** - metrics: 新增 token 成本(USD) 统计\n- **loadShed reaper 与 cooldown 软跳过改进** - scheduler: loadShed reaper 自动推进到期状态、cooldown 软跳过与 scoped limiter 清理改进\n- **preflight 空响应详情诊断与敏感信息脱敏** - stream: preflight 空响应详情诊断与敏感信息脱敏\n\n### 修复\n\n- **MiniMax M2 系列上下文与输出元数据** - model-registry: 修正 MiniMax M2 系列上下文与输出元数据\n- **火山方舟 quota exceeded 识别与临时限流误判** - failover: 识别火山方舟 quota exceeded 并修复临时 token-per-minute 限流误判\n- **桌面渠道模型映射下拉框显示** - desktop: 修复渠道模型映射下拉框显示并清理调试日志\n\n### 文档\n\n- **Microsoft Store submission API 凭据获取说明** - skill: 补充 Microsoft Store submission API 凭据获取说明\n\n### 其他\n\n- **desktop-store-update 技能** - skill: 新增 desktop-store-update 技能\n\n## [v2.9.28] - 2026-07-02\n\n### 新增\n\n- **BIND_HOST 环境变量支持** - server: 新增 BIND_HOST 环境变量以绑定指定网络接口\n- **Claude thinking 回填缓存** - thinkingcache: 新增 Claude thinking 回填缓存模块\n- **子代理精确识别** - agent: 通过 X-Claude-Code-Agent-Id 精确识别子代理\n- **统一配置管理与 CLI 选项** - thinkingcache: 统一 thinking 缓存配置管理与 CLI 选项\n- **DeepSeek Claude thinking 缓存诊断** - compat-diagnose: DeepSeek Claude 渠道启用 thinking 缓存诊断\n- **DeepSeek Claude 预设 thinking 缓存模式** - preset: DeepSeek Claude 预设切换到 thinking 缓存模式\n\n### 修复\n\n- **stop_reason 仅事件误判** - stream: stop_reason 仅事件不再误判为语义内容\n\n### 重构\n\n- **override 日志增强** - scheduler: 增强 override 日志输出\n\n## [v2.9.27] - 2026-07-02\n\n### 新增\n\n- **渠道预设 maxTokens 支持** - desktop/preset: 渠道预设新增 maxTokens 字段并支持桌面端配置\n- **渠道 preset authHeader 字段** - preset: 渠道 preset 新增 authHeader 字段\n- **thinking-only 响应识别** - stream: preflight 支持 thinking-only 响应识别为有效内容\n- **订阅 Tab 优化与 preset 清理** - desktop: 订阅 Tab 优化与 preset channel_types 清理\n- **LongCat-2.0 与 Step 3.7 Flash 模型注册** - models: 新增 LongCat-2.0 与 Step 3.7 Flash 内置模型注册\n\n### 修复\n\n- **轮询立即触发并统一活跃 job 收集** - capability: 修复轮询调度延迟与活跃 job 收集逻辑\n- **兼容上游裸 JSON 行 SSE 流** - claude: 兼容上游裸 JSON 行 SSE 流\n\n### 重构\n\n- **ENV 字段移至服务器配置组** - desktop: 将 ENV 字段移至服务器配置组 PORT 之后\n- **SSE 流式规范化重构** - claude: 重构 SSE 流式规范化并补齐响应必需字段\n- **openai-code-v2 channel_types 移至测试层** - capability: 将 openai-code-v2 channel_types 移至测试层\n\n### 其他\n\n- **Windows MSIX 打包图标资源** - build: 添加 MSIX 打包所需多尺寸图标资源\n\n## [v2.9.26] - 2026-07-01\n\n### 新增\n\n- **Copilot 代理支持** - copilot: OAuth 流程支持代理地址并优化超时处理,支持通过代理进行 Copilot token 交换,新增 Copilot 渠道默认 BaseURL 自动回填\n- **Copilot 订阅中心** - desktop: 新增订阅中心页面,将 Copilot OAuth 从渠道创建移至独立 Tab,支持多账号 key 池管理、多渠道目标与诊断结果展示\n- **Copilot 预设与前端适配** - desktop/preset: 新增 GitHub Copilot 预设并支持 proxyUrl 字段;web/frontend: Copilot 渠道支持代理地址输入,channelPayload 支持 Copilot 默认 BaseURL\n- **订阅 Tab 账号展示增强** - desktop: 增强订阅 Tab 账号展示与 Copilot 模型获取,支持国际化\n- **Claude Sonnet 5 注册** - models: 注册 Claude Sonnet 5 模型\n- **模型元数据增强** - models: 丰富模型元数据,支持 Anthropic 家族分类,优化模型发现机制并添加缓存降级与 keypool 集成\n- **Claude Desktop 连接探测** - messages: 内置 Claude Desktop 连接探测响应\n- **本地 HTTPS 自签名证书** - tls: 支持本地 HTTPS 自签名证书\n- **Codex 原生 WebSocket 协议** - responses: 支持 Codex 原生 WebSocket 协议\n- **商汤日日新接入** - sensenova: 新增商汤日日新 LLM 平台接入\n- **讯飞 Coding Plan 迁移** - preset: 讯飞 Coding Plan 迁移并支持 serviceType 配置\n- **MiMo 计划选择器** - ui: 为 Codex 和 OpenCode 添加 MiMo 计划选择器\n- **超时控件优化** - ui: 将超时控件暴露为秒级显示\n- **macOS 应用内更新** - desktop: 新增 macOS 应用内更新打包支持\n- **会话预览** - ui: 添加可展开的会话预览功能\n- **渠道 Key 复用显示** - desktop: 渠道 Key 复用时显示当前值\n- **image_generation 探测** - diagnose: 探测上游 image_generation tool 支持\n- **渠道编辑交互优化** - channel-editor: 优化渠道编辑交互体验\n\n### 修复\n\n- **copilot serviceType 映射** - capability: 修复 copilot serviceType 映射到 responses 协议\n- **Copilot 订阅 token 复用** - desktop: 修复 Copilot 订阅保存 token 复用与渠道状态管理\n- **copilot BaseURL 清空** - desktop: 修复 copilot 渠道 BaseURL 因空版本前缀被错误清空的问题\n- **normalizeMetadataUserId 协议限制** - config: 将 normalizeMetadataUserId 限定为仅 Messages 协议生效\n- **TLS 证书路径提示** - tls: 证书路径为相对路径时增加绝对路径提示\n- **responses 渠道 normalizeMetadataUserId** - channel-editor: responses 渠道默认关闭 normalizeMetadataUserId\n- **Copilot OAuth 超时上下文** - copilot: 补充 OAuth 请求超时与错误上下文\n- **模型点击排队** - capability-tests: 修复桌面端模型点击排队问题\n- **provider key 资产匹配** - channel: currentAsset 按 planID 精确匹配 provider key 资产\n- **keysByProvider 去重** - preset: keysByProvider 去重保留首个 asset\n- **plan-scoped key 存储** - configservice: plan-scoped key 存储不覆盖 provider channel key\n- **Responses URL 重复拼接** - api: Responses URL 末尾已含 endpoint 时避免重复拼接\n- **MiMo reasoning effort** - api: 规范化 MiMo Responses reasoning effort 上限\n- **Codex tool_search 剥离** - api: 剥离 Codex tool_search 工具调用\n- **compaction 流** - api: 允许 compaction 仅含 usage 的流式响应\n- **MiMo token plans** - desktop: 允许 MiMo token plans 用于 responses 协议\n- **brew deprecated syntax** - brew: 替换已废弃的 `depends_on` 字符串语法\n- **模型能力同步延迟** - ui: 编辑期间延迟模型能力同步\n- **thinking reasoning 参数** - api: 规范化 thinking reasoning 参数\n- **MiMo thinking style** - channel: MiMo 预设使用 thinking style\n- **渠道高级选项布局** - ui: 改善渠道高级选项布局\n\n### 重构\n\n- **ResolveTokenWithProxy** - handlers: 全量替换 Copilot ResolveToken 为 ResolveTokenWithProxy\n- **渠道编辑器 composables 拆分** - channel-editor: 接入 useEditChannelModal 主 composable,提取禁用 Key 恢复/模型获取/Payload 构建、表单选项/派生计算/预设/工具函数、UI 辅助 composables\n- **应用 composables 提取** - app: 提取 useToasts + useAppController composable,提取 savedProviderKeyForPlan 按 planID 匹配资产 key\n- **ChannelEditDialog composables** - desktop: 提取 ChannelEditDialog composables\n- **configservice 瘦身** - configservice: service.go 瘦身为纯调度层,提取 Provider Key 管理、预览/试运行、OpenCode 配置、Codex 配置与会话迁移、路径解析与 Claude 配置、file/toml/jsonc 格式辅助函数\n- **渠道自定义参数面板分离** - ui: 将渠道自定义参数面板拆分为独立组件\n\n### 文档\n\n- 补充桌面端与环境变量文档\n- 清理已完成的上游版本评估条目\n- 添加 Claude Code 2.1.196 上游版本审查\n- 澄清快速模式使用成本说明\n\n### 测试\n\n- 覆盖 Codex tool_search 剥离测试\n\n## [v2.9.25] - 2026-06-29\n\n### 新增\n\n- **Copilot 授权码一键复制** - channel: 为 Copilot 授权码添加一键复制操作,便于完成 OAuth 配置\n\n### 修复\n\n- **提示浮层不被容器裁剪** - ui: 保持 tooltip 显示在被裁剪容器之上\n- **桌面端熔断器弹窗布局** - desktop: 保持熔断器弹窗布局在正常文档流中,避免内容溢出错位\n- **移除全局历史图片限制控件** - ui: 移除全局 historical image limit 控件,避免与渠道级配置混淆\n- **历史图片限制改为渠道级配置** - config: 将 historical image limit 调整为渠道级配置\n\n### 重构\n\n- **抽取 Copilot OAuth 路径常量** - channel: 提取 Copilot OAuth 路径常量并修复授权码清理逻辑\n\n### 文档\n\n- **澄清渠道级图片限制变更** - changelog: 澄清历史图片限制已改为渠道级配置\n\n## [v2.9.24] - 2026-06-29\n\n### 新增\n\n- **MiMo 预设思考强度配置** - channel: 将 MiMo 预设的思考强度配置学习到一键预设\n\n### 修复\n\n- **Volc Ark coding 端点预设** - desktop: 修正 Volc Ark coding 端点预设配置\n- **渠道日志与 Key 错误展示** - desktop: 改善渠道日志和 key 错误信息的显示\n- **认证区域置于重定向前** - channel: 将认证配置区域放在重定向区域之前\n- **Copilot OAuth 外部链接** - desktop: 修复 Copilot OAuth 链接在桌面端无法打开的问题\n\n### 其他\n\n- **延长桌面端 macOS 公证超时** - release: 延长桌面端 macOS 公证任务超时时间\n\n## [v2.9.23] - 2026-06-29\n\n### 修复\n\n- **剥离不支持的 Claude thinking blocks** - responses: strip unsupported Claude thinking blocks\n- **优化 Claude 兼容性诊断** - compat: refine Claude compatibility diagnosis\n- **刷新熔断窗口近期历史** - metrics: refresh breaker windows from recent history\n\n### 测试\n\n- **稳定 thinking 兼容性覆盖** - responses: stabilize thinking compatibility coverage\n\n### 其他\n\n- **延长 macOS 公证超时时间** - release: extend macOS notarization timeout\n\n## [v2.9.21] - 2026-06-28\n\n### 新增\n\n- **渠道日志复制操作** - channel: 为渠道日志添加复制操作,便于快速复制日志内容排查问题\n- **GPT-5.6 能力探测与渠道选项** - capability: 接入 GPT-5.6 能力探测与渠道配置选项\n- **GPT-5.6 Amazon Bedrock 模型定义** - model-registry: 新增 GPT-5.6 Amazon Bedrock 模型定义\n\n### 修复\n\n- **保留 Claude 主 API Key** - desktop: 修复桌面端配置同步时 Claude 主 API key 被覆盖或丢失的问题\n- **Base URL 哈希诊断校验** - 修复 base url hash 诊断校验逻辑,提升问题定位准确性\n- **桌面端流式超时默认值** - desktop: 对齐并保留 stream timeout 默认值\n- **渠道菜单浮层稳定性** - frontend: 稳定渠道操作菜单 overlay 展示,避免交互抖动\n- **Copilot OAuth 国际化文案** - desktop: 补齐 Copilot OAuth 桌面端多语言文案\n- **输出 token 按模型限制裁剪** - routing: 根据模型能力限制下调超限输出 token,避免上游参数错误\n- **能力探测携带必需 thinking 参数** - capability: 强制思考模型探测时自动携带 thinking/reasoning 参数,避免能力误判\n- **Kimi K2.7 输出限制修正** - model-registry: 将 Kimi-K2.7-Code 最大输出与推荐输出修正为 32768\n\n## [v2.9.20] - 2026-06-27\n\n### 新增\n\n- **展示 Claude Code recap** - cockpit: 展示 Claude Code recap\n- **记录并展示 Claude Code recap** - cockpit: 记录并展示 Claude Code recap\n\n### 其他\n\n- **限制 macOS 公证任务时长** - release: 限制 macOS 公证任务时长\n- **并行化 macOS 签名公证流程** - release: 并行化 macOS 签名公证流程\n\n## [v2.9.19] - 2026-06-27\n\n### 新增\n\n- **为对话驾驶舱补充悬浮提示** - ui: 为对话驾驶舱补充悬浮提示\n\n### 修复\n\n- **strip Codex response metadata passthrough** - strip Codex response metadata passthrough\n- **处理 Responses 流式错误事件** - responses: 处理 Responses 流式错误事件\n- **补齐印尼语缺失翻译** - i18n: 补齐印尼语缺失翻译\n- **跳过 Claude 空输出重试标题** - backend: 跳过 Claude 空输出重试标题\n- **remove channel logs refresh button** - desktop: remove channel logs refresh button\n\n### 重构\n\n- **抽取 Responses WebSocket 回退处理器** - backend: 抽取 Responses WebSocket 回退处理器\n\n### 其他\n\n- **配置 macOS 签名公证** - desktop: 配置 macOS 签名公证\n\n## [v2.9.18] - 2026-06-26\n\n### 新增\n\n- **捕获 WebView 前端运行时错误** - desktop: 捕获 WebView 前端运行时错误\n- **支持前端错误写入桌面日志** - desktop: 支持前端错误写入桌面日志\n- **同步渠道预设兼容选项** - channel: 同步渠道预设兼容选项\n- **优化兼容诊断默认建议** - channel: 优化兼容诊断默认建议\n- **展示主对话最新消息** - frontend: 展示主对话最新消息\n- **更新 MiMo V2.5 模型能力** - model-registry: 更新 MiMo V2.5 模型能力\n- **添加兼容性诊断入口** - channel: 添加兼容性诊断入口\n- **添加兼容性诊断** - channel: 添加兼容性诊断\n- **优化驾驶舱展开对话卡片展示** - ui: 优化驾驶舱展开对话卡片展示\n- **添加驾驶舱关联对话导航** - ui: 添加驾驶舱关联对话导航\n- **GitHub Copilot 共享认证支持与安全加固** - GitHub Copilot 共享认证支持与安全加固\n- **支持 Chat/Messages/Gemini 入口转换到 Copilot** - copilot: 支持 Chat/Messages/Gemini 入口转换到 Copilot\n- **优化添加渠道引导与文档** - copilot: 优化添加渠道引导与文档\n- **优化前端诊断结果展示** - copilot: 优化前端诊断结果展示\n- **增加 OAuth 重试/取消与渠道诊断** - copilot: 增加 OAuth 重试/取消与渠道诊断\n- **补齐 ping、错误分类与 OAuth 状态展示** - copilot: 补齐 ping、错误分类与 OAuth 状态展示\n- **支持 GitHub Copilot OAuth 与 Responses 上游接入** - copilot: 支持 GitHub Copilot OAuth 与 Responses 上游接入\n- **ConversationCard 展示子代理列表与状态摘要** - web: ConversationCard 展示子代理列表与状态摘要\n- **支持流式会话状态追踪** - conversation: 支持流式会话状态追踪\n\n### 修复\n\n- **区分主对话与子代理覆盖状态** - ui: 区分主对话与子代理覆盖状态\n- **支持仅子代理渠道覆盖** - backend: 支持仅子代理渠道覆盖\n- **覆盖 Gemini 内联图并加固图片 token 估算** - utils: 覆盖 Gemini 内联图并加固图片 token 估算\n- **按真实尺寸估算内联 base64 图片的 token** - utils: 按真实尺寸估算内联 base64 图片的 token\n- **跳过空 tool_calls 避免 text block 被拆分** - providers: 跳过空 tool_calls 避免 text block 被拆分\n- **移除折叠态子代理摘要** - frontend: 移除折叠态子代理摘要\n- **按展开状态置顶对话卡片** - ui: 按展开状态置顶对话卡片\n- **修正对话卡片展开展示** - ui: 修正对话卡片展开展示\n- **跳过 Claude Command 文档标题回退** - messages: 跳过 Claude Command 文档标题回退\n- **优化对话卡片展开展示** - ui: 优化对话卡片展开展示\n- **同步单轮主对话预览截断** - desktop: 同步单轮主对话预览截断\n- **优化单轮主对话预览截断** - frontend: 优化单轮主对话预览截断\n- **拆分驾驶舱主对话轮次展示** - frontend: 拆分驾驶舱主对话轮次展示\n- **提供全局提示组件上下文** - desktop: 提供全局提示组件上下文\n- **优化驾驶舱对话 ID 展示** - ui: 优化驾驶舱对话 ID 展示\n- **优化驾驶舱窄屏工具栏布局** - desktop: 优化驾驶舱窄屏工具栏布局\n- **优化渠道搜索框响应式宽度** - channel: 优化渠道搜索框响应式宽度\n- **优化驾驶舱窄屏筛选布局** - frontend: 优化驾驶舱窄屏筛选布局\n- **添加搜索框清除按钮** - desktop: 添加搜索框清除按钮\n- **优化驾驶舱筛选栏布局** - frontend: 优化驾驶舱筛选栏布局\n- **优化驾驶舱对话展示** - frontend: 优化驾驶舱对话展示\n- **精简驾驶舱统计卡片** - frontend: 精简驾驶舱统计卡片\n- **修正子代理总数统计** - frontend: 修正子代理总数统计\n- **过滤重复对话卡片** - frontend: 过滤重复对话卡片\n- **过滤重复渠道索引** - frontend: 过滤重复渠道索引\n- **修正驾驶舱卡片与统计文案** - frontend: 修正驾驶舱卡片与统计文案\n- **延迟加载全局统计图** - desktop: 延迟加载全局统计图\n- **保留快速输入清理后的 URL** - frontend: 保留快速输入清理后的 URL\n- **修正快速输入 URL 清理** - frontend: 修正快速输入 URL 清理\n- **新建/编辑渠道默认开启自动学习上游限速** - frontend: 新建/编辑渠道默认开启自动学习上游限速\n- **用 unref() 包装 store 响应式值** - frontend: 用 unref() 包装 store 响应式值\n- **过滤标题回退中的注入上下文** - backend: 过滤标题回退中的注入上下文\n- **修复托盘开机自启切换状态** - desktop: 修复托盘开机自启切换状态\n- **调整统一会话 ID 提取优先级** - backend: 调整统一会话 ID 提取优先级\n- **更新火山方舟控制台链接** - desktop: 更新火山方舟控制台链接\n- **驾驶舱展开卡片排序稳定** - Web 与桌面端会话驾驶舱在存在展开卡片时固定当前卡片顺序,避免轮询刷新或最新消息更新时间导致卡片跳动;全部收起后恢复按最新消息排序,并补充桌面端排序稳定性测试\n- **兼容 vLLM 响应中的 reasoning 字段** - providers: 兼容 vLLM 响应中的 reasoning 字段\n- **清理会话反馈残留引用** - backend: 清理会话反馈残留引用\n- **移除驾驶舱会话反馈输入框** - ui: 移除驾驶舱会话反馈输入框\n- **移除驾驶舱卡片展开折叠按钮** - ui: 移除驾驶舱卡片展开折叠按钮\n- **调整驾驶舱渠道列表高度** - ui: 调整驾驶舱渠道列表高度\n- **修复 customHeaders 覆盖 Copilot token 的安全漏洞** - copilot: 修复 customHeaders 覆盖 Copilot token 的安全漏洞\n- **修复响应协议转换与认证头泄露** - copilot: 修复响应协议转换与认证头泄露\n- **修复 ConversationChannelSequence Vuetify CSS 颜色变量 alpha 写法** - ui: 修复 ConversationChannelSequence Vuetify CSS 颜色变量 alpha 写法\n- **Compshare 月度额度耗尽自动拉黑** - `extractErrorInfo`/`extractErrorCode` 兼容大写字段名 (`RetCode`/`Message`) 和数字类型错误码;`isInsufficientBalanceMessage` 增加 `monthly limit exceeded` 等精确短语,使 429 + 月度额度耗尽错误能正确触发 key 拉黑\n- **收敛会话卡片高度避免撑爆页面** - cockpit: 收敛会话卡片高度避免撑爆页面\n- **保留原始元数据识别子代理** - conversation: 保留原始元数据识别子代理\n- **增强调试日志定位响应协议** - messages: 增强调试日志定位响应协议\n- **补齐驾驶舱看板文案** - i18n: 补齐驾驶舱看板文案\n- **注册驾驶舱图标** - web: 注册驾驶舱图标\n- **恢复驾驶舱顶部入口** - web: 恢复驾驶舱顶部入口\n\n### 优化\n\n- **优化控制台图表长周期采样** - desktop: 优化控制台图表长周期采样\n- **优化历史图表长周期采样** - frontend: 优化历史图表长周期采样\n\n### 重构\n\n- **清理会话反馈功能** - 清理会话反馈功能\n- **提取 ConversationChannelSequence 组件,消除主对话/子代理渠道列表重复** - ui: 提取 ConversationChannelSequence 组件,消除主对话/子代理渠道列表重复\n- **子代理渠道交互改为行列表,与主对话保持一致** - cockpit: 子代理渠道交互改为行列表,与主对话保持一致\n- **同步驾驶舱 BoardItem 模型到桌面端** - desktop: 同步驾驶舱 BoardItem 模型到桌面端\n- **Dashboard 适配 ConversationBoardItem 结构** - web: Dashboard 适配 ConversationBoardItem 结构\n- **重构驾驶舱子代理分组与聚合逻辑** - web: 重构驾驶舱子代理分组与聚合逻辑\n- **抽取驾驶舱列分组工具** - web: 抽取驾驶舱列分组工具\n\n### 测试\n\n- **补齐 vLLM reasoning 兼容覆盖** - providers: 补齐 vLLM reasoning 兼容覆盖\n- **强化 Gemini 图片估算测试并提速** - utils: 强化 Gemini 图片估算测试并提速\n- **补齐 copilot 包 httptest 覆盖** - copilot: 补齐 copilot 包 httptest 覆盖\n\n### 文档\n\n- **记录 Claude Code v2.1.193 变更评估** - todo: 记录 Claude Code v2.1.193 变更评估\n- **完善兼容选项提示** - frontend: 完善兼容选项提示\n- **记录 Codex 上游变更评估** - todo: 记录 Codex 上游变更评估\n- **更新 CHANGELOG 记录 Compshare 月度额度拉黑修复** - 更新 CHANGELOG 记录 Compshare 月度额度拉黑修复\n\n### 其他\n\n- **升级前端依赖** - deps: 升级前端依赖\n- **更新 Go 与 Wails 相关依赖** - deps: 更新 Go 与 Wails 相关依赖\n\n## [v2.9.17] - 2026-06-23\n\n### 新增\n\n- **桌面端会话父子关系导航** - 展示会话父子关系导航,便于在关联会话间跳转\n- **关联子代理会话记录** - 支持记录与展示子代理关联会话\n- **桌面端会话驾驶舱任务看板** - 改造会话驾驶舱任务看板,增强子代理任务概览\n- **桌面端独立驾驶舱入口** - 添加独立驾驶舱入口,提升访问效率\n- **会话反馈记录** - 支持记录会话反馈信息\n- **驾驶舱子代理看板改进** - 改进 cockpit subagent board 展示能力\n\n### 修复\n\n- **统计图表长跨度采样与展示** - 将 Web 与桌面端全局/渠道统计图默认采样点控制在 200 以内,并修复渠道级图表在仅最近几天有数据时选择 1 年导致曲线被全年空白压缩的问题\n- **桌面端能力测试渠道复制反馈** - 改进能力测试渠道复制反馈体验\n\n### 重构\n\n- **桌面端侧边栏导航分组拆分** - 拆分侧边栏导航分组,优化导航结构\n- **桌面端驾驶舱入口分离** - 将驾驶舱入口从管理控制台分离\n\n### 文档\n\n- **已完成 TODO 清理** - 移除已完成 TODO 项\n\n### 其他\n\n- **统计图表采样展示修复合并** - 合并统计图表采样展示修复\n- **桌面端开发模式启动顺序** - 调整开发模式前端启动顺序\n- **桌面端开发壳构建任务** - 添加开发壳构建任务\n\n## [v2.9.16] - 2026-06-23\n\n### 修复\n\n- **Docker 共享前端工具嵌入** - Docker 构建包含共享前端工具文件,避免容器构建缺失共享工具\n\n## [v2.9.15] - 2026-06-23\n\n### 新增\n\n- **Kimi K2.7 能力别名** - 添加 Kimi K2.7 模型能力别名,支持默认模型能力识别\n- **Qwen3.7 模型阶梯定价** - 添加 Qwen3.7 模型阶梯定价配置\n- **Codex tool_search_output 合并** - Responses 支持合并 Codex `tool_search_output` 工具输出\n- **桌面端模型下拉自动滚动定位** - 添加模型下拉列表自动滚动定位功能\n- **版本升级构建监控** - version-bump 技能新增构建进度监控流程\n\n### 修复\n\n- **Codex Responses 默认模型** - 更新 Codex Responses 共享预设默认模型\n- **桌面端渠道中心独立滚动** - 修复渠道中心滚动区域交互\n- **能力检测重试一致性** - 修复 capability retry consistency\n- **桌面端能力检测提示层裁剪** - 修复能力检测模型提示层被裁剪的问题\n- **MiMo Codex 兼容预设** - 开启 MiMo Codex 兼容预设\n- **桌面端源模型预设过滤** - 过滤已配置的源模型预设选项\n- **桌面端目标模型下拉滚动定位** - 修复目标模型下拉滚动定位\n- **桌面端目标模型完全匹配显示** - 修复目标模型完全匹配时不显示窗口的问题\n- **桌面端源模型选择展开** - 修复源模型选择后再次点击不展开列表的问题\n- **桌面端源模型下拉显示** - 修复源模型下拉列表不显示的问题\n- **桌面端模型重定向空胶囊** - 移除模型重定向源模型输入框的空胶囊元素\n\n### 重构\n\n- **渠道协议预设抽取** - 抽取渠道协议预设\n- **Codex Responses 共享预设抽取** - 抽取 Codex Responses 共享预设\n- **前端模型优先级排序共享** - 共享模型优先级排序逻辑\n\n### 测试\n\n- **用户额度不足黑名单用例** - 补充用户额度不足黑名单用例\n\n## [v2.9.14] - 2026-06-22\n\n### 新增\n\n- **Token 统计时间范围扩展到 1 年** - 新增 90 天、半年、1 年、今年等长时间范围选项\n - 后端数据保留期默认值:30 天 → 366 天,上限:90 天 → 366 天(闰年全年覆盖)\n - API 时间范围上限:30 天 → 366 天\n - 新增时间格式支持:`90d`、`180d`、`365d`、`thisyear`\n - 聚合间隔优化:长时间范围使用 8-12 小时间隔,控制数据点数量\n - Web 端和桌面端所有图表组件同步更新\n - 统一两端时间选项文案:桌面端 `7d`/`30d` → `1周`/`1月`\n - 新增中文、英文、印尼文三语言国际化文案\n- **Unity2.ai 赞助商集成** - 新增 Unity2.ai 赞助商渠道预设与国际化文案支持\n- **Unity2.ai 国际化文案** - 添加 Unity2.ai 渠道预设翻译\n- **自动赞助商集成技能** - 新增 add-sponsor skill,支持自动化赞助商集成流程\n- **对齐 Web 与桌面端渠道菜单行为** - 统一两端渠道操作菜单交互逻辑\n\n### 修复\n\n- **Codex Responses 共享预设默认模型** - 将 Kimi 默认模型同步为 `kimi-k2.7` 并补齐能力注册,GLM 默认模型升级为 `glm-5.2`,避免 Web/desktop 一键预设回退到旧模型\n- **Unity2.ai 和 RunAPI 响应剥离图片生成工具** - 为 Unity2.ai 和 RunAPI 响应剥离 image generation tool\n- **Unity2.ai 使用原生 responses 协议** - Unity2.ai 改用原生 responses 协议\n- **Unity2.ai 添加 anthropic plan 支持 Messages 目标** - 为 Unity2.ai 添加 anthropic plan 以支持 Messages target\n- **Unity2.ai targets 顺序修正** - 修正 Unity2.ai targets 顺序为标准 Messages → Responses → Chat\n- **恢复原生 tool_search 调用** - 恢复 Codex 原生 tool_search calls\n- **为 tool_search stream 发送 custom_tool_call_input.done** - 确保 tool_search 流式响应完整性\n- **保留 tool_search 历史参数** - 保持 Codex tool_search history arguments\n- **恢复 tool_search 自定义调用语义** - 恢复 tool_search custom call semantics\n- **统一两端高级选项选择框宽度与布局** - 统一 Web 和桌面端渠道编辑器高级选项布局\n- **tool_search 保留原始参数 schema 映射为 function** - converters 中 tool_search 保留原始参数 schema 映射\n- **统一渠道菜单文案与 Web 端** - 桌面端渠道菜单文案对齐 Web 端\n- **保留搜索结果渠道置顶置底操作** - 桌面端搜索结果中保留渠道排序操作\n\n### 重构\n\n- **预设配置辅助函数抽取** - 使用 helper functions 减少 preset 配置重复代码\n\n### 测试\n\n- **Codex chat 子代理工具调用测试覆盖** - 为 chat sub-agent tool calls 添加测试覆盖\n\n### 文档\n\n- **更新 add-sponsor skill 文档** - 更新 add-sponsor skill 最新使用模式\n\n### 其他\n\n- **移除 add-sponsor.sh 脚本** - 删除旧的 add-sponsor shell 脚本\n\n## [v2.9.13] - 2026-06-21\n\n### 新增\n\n- **子代理感知对话路由与亲和隔离** - 新增 subagent-aware conversation routing 与 affinity isolation,减少不同子代理会话间的路由串扰\n- **额外代理访问密钥** - 支持额外代理访问密钥配置,扩展代理入口鉴权能力\n- **内置模型预设匹配改用正则表达式** - builtin model registry 的 patterns 从简单通配符(`claude-opus-4-7*`)改为显式正则,精确控制前后边界,避免 `gpt-5.4-mini` 被 `gpt-5.4` 误吃、`glm-5.1` 被 `glm-5` 误吃等歧义;支持厂商前缀(`bedrock/claude-opus-4-7`、`xxx-claude-opus-4-7`)和日期版本号后缀(`-20260101`),同时拒绝 `claude-haiku-4-5-with-claude-opus-4-7-fallback` 这类异常组合名\n - 一个 entry 支持多个 pattern(如 `kimi-for-coding` 别名回退到 `kimi-k2.7-code`),未来 Kimi 升级到 K2.8 只需把别名移到新 entry\n - 前端 `channelPayload.ts`、桌面端 `channel-payload.ts` 匹配函数改用 `new RegExp(pattern, 'i')`\n - 后端 `model_registry.go` 新增 `builtinPatternCache` 预编译缓存,RE2 兼容剥离 `(?=$|@)` lookahead 后手动检查边界;`resolvePatternValueFold` 先正则再通配符\n - generator 加构建时正则校验\n - **用户路由级 supportedModels 配置不受影响**,继续用通配符语义\n- **手动暂停与自动熔断区分显示** - UI 区分手动暂停与自动熔断状态展示\n- **images 渠道纳入模型聚合** - 模型列表聚合纳入 images 渠道,并支持多渠道并行收集聚合\n- **Key 权重与模型选择** - Key 池新增 Weight/Models 选择能力,支持 metadata restore 测试并在前端加载 `apiKeyConfigs`\n- **按 Key 凭证池与限速** - 新增 per-key credential pool,支持按 Key 作用域进行限速\n\n### 修复\n\n- **调度器文件拆分冲突修复** - 解决 worktree 合并后的 scheduler file-split 冲突\n- **驾驶舱交互与 Key 管理** - 修复桌面端驾驶舱交互与 Key 管理若干问题\n- **驾驶舱暂停/熔断状态徽标区分** - 区分暂停与熔断状态 badge 并统一文案\n- **驾驶舱渠道选择交互** - 修复驾驶舱渠道选择交互问题\n- **渠道统计图表交互对齐** - 对齐渠道统计图表交互逻辑\n- **重复 Key 提示** - 添加重复 Key 时给出明确提示\n- **桌面 dashboard 控制台交互** - 修复桌面端 dashboard console interactions\n- **Responses 转换保持 Codex 工具兼容** - preserve Codex tool compatibility in responses conversion\n- **Dashboard 渠道编辑弹窗** - 修复桌面端 dashboard 渠道编辑弹窗问题\n- **限速高水位调度优化** - 优化调度器限速高水位策略\n- **渠道统计包含黑名单 Key** - channel stats 纳入 blacklisted keys\n- **渠道编辑对话框高度** - 优化渠道编辑对话框高度\n- **黑名单快照与配置监听** - 加固 blacklist snapshot 与 config watcher\n- **Code review 反馈问题修复** - 修复 code review 确认的 11 个 keypool/ratelimit/config 问题\n- **ChannelView 返回 apiKeyConfigs** - BuildChannelView 返回 `apiKeyConfigs`\n- **模型映射同步后再能力测试** - 前端 capability test 前同步 model mapping\n\n### 重构\n\n- **配置更新逻辑抽取** - 抽取 `applyAPIKeyConfigUpdate` 并改进 watcher 防抖策略\n- **前端活动可视化与单测** - 改进 activity visualization 并补充 unit tests\n- **ChannelOrchestration 样式拆分** - 从 `ChannelOrchestration.vue` 抽取样式\n- **EditChannelModal 样式与优先级逻辑拆分** - 从 `EditChannelModal.vue` 抽取样式和 model-priority 逻辑\n- **App 样式与能力测试组合式函数拆分** - 从 `App.vue` 抽取样式和 capability-test composable\n- **ChannelOrchestration 活动组合式函数抽取** - 抽取 `useChannelActivity` composable\n- **Responses handler 拆分** - 拆分 `handler.go`(2160 行 → 5 个文件)\n- **Chat handler 拆分** - 拆分 `handler.go`(2305 行 → 5 个文件)\n- **Scheduler 测试拆分** - 拆分 `channel_scheduler_test.go`(1650 行 → 3 个文件)\n- **Scheduler 实现拆分** - 拆分 `channel_scheduler.go`(1535 行 → 4 个文件)\n- **通用 stream helper 拆分** - 拆分 `handlers/common/stream.go`(2799 行 → 4 个文件)\n- **Metrics 实现拆分** - 拆分 `channel_metrics.go`(3731 行 → 5 个文件)\n\n### 文档\n\n- **待办事项更新** - 更新 TODO 文档\n\n### 其他\n\n- **额外代理访问密钥合并** - 合并额外代理访问密钥支持\n- **渠道编辑对话框高度修复合并** - 合并渠道编辑对话框高度修复\n- **黑名单 Key 统计修复合并** - 合并 blacklisted key stats fix\n- **Go 格式化** - 执行 go fmt 格式化\n\n## [v2.9.12] - 2026-06-19\n\n### 修复\n\n- **上下文窗口按输入 token 过滤** - 调度器按输入 token 过滤 context windows,避免选择不满足输入上下文要求的渠道\n- **Docker 前端嵌入构建稳健性改进** - 改进 Docker frontend embed build robustness,提升前端产物嵌入构建稳定性\n\n## [v2.9.11] - 2026-06-19\n\n### 修复\n\n- **桌面控制台 fallback 文案本地化** - 桌面端控制台 fallback labels 使用多语言文案,避免未翻译标签展示\n\n## [v2.9.10] - 2026-06-19\n\n### 新增\n\n- **上游认证头覆盖配置** - 后端新增 upstream auth header override,支持按渠道覆盖上游认证头\n- **渠道认证头设置入口** - 前端暴露 channel auth header 设置,支持管理界面配置认证头覆盖\n- **OpenCode Go 预设 x-api-key** - 桌面端 OpenCode Go 预设自动设置 x-api-key 认证头\n\n### 修复\n\n- **模型能力标签简化** - 简化前端 model capability labels,改善模型能力展示文案\n\n### 其他\n\n- **SignPath artifact 安全暂存** - SignPath artifacts 使用安全名称暂存,避免签名流程中的产物名称冲突\n\n## [v2.9.9] - 2026-06-19\n\n### 其他\n\n- **SignPath 签名步骤唯一 artifact 名称** - 为 SignPath 签名步骤添加唯一 artifact 名称,避免多平台并行构建时名称冲突\n\n## [v2.9.8] - 2026-06-19\n\n### 新增\n\n- **OpenCode Zen/Go 渠道预设更新至 glm-5.2** - 桌面端 OpenCode Zen/Go 预设的 Claude 与 Codex 目标模型映射更新为 glm-5.2 / deepseek-v4-flash,并补齐对应 reasoning 映射\n\n### 修复\n\n- **视觉回退模型上下文能力自动添加** - 前端和桌面端在上下文能力自动添加时纳入视觉回退模型,并按大小写不敏感方式去重,避免回退模型缺失能力配置行\n- **前端 lint 存量问题清理** - 修复 Web 前端 ESLint 存量错误与警告,清理编辑渠道弹窗拆分后的未使用旧逻辑并补齐相关类型声明\n- **Responses SSE 扫描缓冲区上限提升** - 将 Responses 流式 SSE 单行扫描上限提升至 32MB,避免图片/产物等大事件被截断导致流式失败\n\n### 其他\n\n- **upstream-check seen 标签数量限制** - 限制 upstream-check skill 记录的 seen upstream tags 数量,避免状态无限增长\n\n## [v2.9.7] - 2026-06-19\n\n### 新增\n\n- **模型响应模态信息增强** - 增强 models 接口响应中的 modalities 信息\n\n### 修复\n\n- **前端模型目标建议修复** - 修复前端模型 target suggestions 展示与匹配问题\n- **发布暂存目录可见性修复** - 使用可见 release staging directory,修复发布打包暂存路径问题\n\n## [v2.9.6] - 2026-06-19\n\n### 修复\n\n- **移除 agent profile 窗口硬最低限制** - 移除桌面端 agent profile 窗口硬最低限制,新增上下文估算日志\n- **简化模型能力定价编辑器** - 简化前端 model capability pricing editor 交互\n- **修复 capability test 模型映射复用** - 修复 capability test 模型映射复用问题\n- **优化模型能力行布局** - 优化桌面端 model capability row layout\n- **改善 capability test 状态处理** - 改善桌面端 capability test status handling\n- **同步桌面端模型能力配置** - 同步桌面端 model capabilities 配置\n\n## [v2.9.5] - 2026-06-19\n\n### 新增\n\n- **桌面端模型能力编辑器改进** - 改进桌面端 model capability editor 交互与布局\n- **前端模型能力编辑器改进** - 改进前端 model capability editor 交互与布局\n- **DeepSeek V4 能力更新** - 更新 model registry 中 DeepSeek V4 模型能力信息\n\n### 其他\n\n- **SignPath 测试签名校验放宽** - 放宽 SignPath test signature validation,提升测试环境签名校验兼容性\n\n## [v2.9.4] - 2026-06-19\n\n### 新增\n\n- **上游模型能力注册表** - 新增 upstream model capability registry,用于集中维护上游模型能力信息\n- **上下文感知渠道路由** - 新增 context-aware channel routing,根据请求上下文与模型能力选择更合适的渠道\n\n### 修复\n\n- **桌面端目标模型选项来源修复** - 桌面端使用已拉取的目标模型选项,避免候选模型与上游能力不一致\n- **桌面端渠道能力测试流程对齐** - 对齐桌面端渠道 capability test 流程,提升测试结果一致性\n- **桌面端渠道重排序类型处理修复** - 修复桌面端 channel reorder 的类型处理问题\n\n### 其他\n\n- **SignPath 签名集成** - CI 集成 SignPath signing,完善发布签名流程\n\n## [v2.9.3] - 2026-06-18\n\n### 新增\n\n- **讯飞星辰渠道预设** - 新增 ProviderXFyun 预设,Anthropic + OpenAI 双入口,支持 Messages/Chat/Responses 三目标,含快速输入 URL 识别与中英文 locale\n- **Codex SQLite 迁移修复 visibility 字段** - migrateCodexStateDB 动态检测 threads 列,自动回填 preview/has_user_event/thread_source 等隐藏线程可见性字段\n- **中转站错误码 failover 识别增强** - 增强中转站错误码识别与黑名单判定,提升故障转移覆盖\n- **渠道运行态冷却与上游账号池降级** - 调度器新增渠道运行态冷却与上游账号池不可用降级策略\n- **Compshare glm-5.2 预设与推理映射同步** - 桌面端 ChannelEditDialog 同步 Compshare glm-5.2 预设与 reasoning 映射\n- **火山方舟 Compshare 预设更新至 glm-5.2** - 更新桌面端火山方舟 Compshare 渠道预设模型\n\n### 修复\n\n- **渠道日志按稳定渠道名过滤共享 metricsKey** - GetChannelLogs 对共享 metricsKey 优先按创建时渠道名筛选,旧日志回退 channelIndex,避免删除/重排后日志串台\n- **Codex SQLite visibility 回填来源保护** - 迁移 threads 时根据 source 列跳过 exec 等后台线程,避免误标记为用户会话\n- **trace 亲和性遵守 SupportedModels** - 新增测试验证亲和渠道不支持当前模型时正确跳过并回退优先级选择\n- **共享 metricsKey 渠道日志按渠道名归属** - 修复共享 metricsKey 场景下渠道日志归属到稳定渠道名\n- **共享 metricsKey 渠道日志按 channelIndex 过滤** - 修复渠道日志在共享 metricsKey 时按 channelIndex 过滤\n- **短流 EOF 重试策略** - 在 message_stop 前遇到短流 EOF 时进行重试\n- **新建渠道使用生成名称提交** - 修复桌面端新建渠道提交时未使用生成名称的问题\n\n### 文档\n\n- **CHANGELOG 维护更新** - 更新 CHANGELOG,补充讯飞星辰、Codex 迁移修复、日志过滤与调度测试记录\n\n## [v2.9.2] - 2026-06-18\n\n### 新增\n\n- **火山方舟预设模型与推理映射更新** - 更新桌面端火山方舟渠道预设模型与 reasoning 映射\n- **渠道卡片缓存命中率展示** - 桌面端渠道卡片新增缓存命中率显示\n- **渠道添加偏好卡片式交互** - 渠道添加流程改为卡片式交互,提升偏好选择体验\n- **渠道日志 reasoning effort 展示同步** - 桌面端渠道日志同步展示 reasoning effort 信息\n- **渠道支持选择队列末尾并记住偏好** - 前端新增渠道支持放到队列末尾,并记住用户偏好\n- **CompactionV2 本地 compact 与 SSE 转换** - Responses 实现 CompactionV2 本地压缩并支持 SSE 转换\n- **Passthrough converter ResponsesItem 字段映射补全** - passthrough converter 补齐 ResponsesItem 全量字段映射\n- **ResponsesItem EncryptedContent 字段** - 类型定义新增 EncryptedContent 字段以支持 compaction\n- **视觉回退 reasoning effort 与 output_config.effort 同步** - 视觉回退模型支持 reasoning effort,并同步 output_config.effort\n- **渠道日志 reasoning effort chips 展示** - 前端渠道日志弹窗新增 reasoning effort chips\n- **视觉回退 reasoning effort 选择器与多语言文案** - 前端新增视觉回退 reasoning effort selector 和 locale keys\n- **Claude 渠道 reasoning mapping 支持** - 前端扩展 reasoning mapping 支持到 claude channel type\n- **Claude thinking effort 渠道映射注入** - providers 根据渠道 reasoningMapping 注入 Claude thinking effort\n- **ChannelLog reasoning effort 字段** - metrics 新增 originalReasoningEffort 与 actualReasoningEffort\n- **渠道日志 reasoning effort 提取 helpers** - handlers 新增 reasoning effort 提取辅助函数\n\n### 修复\n\n- **火山方舟 Responses 视觉回退补齐** - 桌面端补齐火山方舟 Responses 视觉回退配置\n- **orchestration promotion 标签文案优化** - 优化桌面端 orchestration promotion 标签文案\n- **legacy reasoning params 转换顺序修复** - providers 在重写为 block format 前先转换 legacy reasoning params\n- **模型映射 noVision 同步修复** - 按目标模型改进 noVision 在 model mappings 间的同步逻辑\n- **模型映射 noVision flag 同步修复** - 修复 noVision flag 按 target 在 model mappings 间同步\n- **Add/Edit Channel Modal 缺失导入修复** - 补充 AddChannel/EditChannel modals 中 normalizeSelectableString 导入\n- **Chat reasoning effort 映射模型来源修复** - Chat 使用实际请求体 model 进行 reasoning effort mapping\n- **重复 reasoning effort 标签合并** - 合并 UI 中重复的 reasoning effort 标签显示\n\n### 重构\n\n- **EditChannelModal 请求 URL 构建统一** - 统一 EditChannelModal 使用 buildExpectedRequestUrls\n- **AddChannelModal 快速添加职责收敛** - 移除 AddChannelModal 编辑模式,仅保留 quick-add\n- **reasoning effort 校验逻辑统一** - config 使用 isValidReasoningEffort 统一 reasoning effort validation\n\n## [v2.9.0] - 2026-06-17\n\n### 新增\n\n- **运行时超时热更新与 responseHeaderTimeout 渠道字段** - 新增 `responseHeaderTimeoutMs` 渠道字段,支持全局运行时超时热更新,httpclient 支持显式 responseHeaderTimeout\n- **熔断器/调校台新增超时滑块** - 桌面端和前端熔断器、调校台新增 `requestTimeoutMs` 与 `responseHeaderTimeoutMs` 运行时滑块\n- **CompactModel 配置支持** - Responses 渠道新增 `compactModel` 配置字段,支持压缩模型自动选择\n- **内联模型映射编辑器** - 所有渠道类型支持内联编辑模型映射,无需弹窗\n- **主动限速滑动窗口算法** - 令牌桶改为滑动窗口算法,支持可配置的窗口时长,自动学习上游限速默认开启\n- **b64_json 图片响应自动转换** - 支持 `b64_json` 请求的 URL 响应自动转换\n- **向下游下发可读的空响应错误文案** - 流式响应场景向客户端下发可读错误信息而非空流\n- **桌面端 App 级 polling 调度层** - ConversationDashboard 轮询迁移至 App 级调度,支持 window visibility 事件控制\n- **桌面端图表功能对齐 Web 端** - 桌面端控制面板使用统计图表与 Web 端完全对齐\n- **渠道活动图表背景** - ChannelCard 新增活动图表背景可视化\n- **渠道快速创建卡片上游类型品牌色与图标** - 快速创建渠道卡片的上游类型展示品牌色和对应图标\n- **渠道编辑器输出冗长度配置** - 渠道编辑器新增输出冗长度配置选项\n- **驾驶舱空闲自动恢复时间持久化配置** - 支持通过环境变量和前端选择器持久化配置 override 有效期\n- **Kimi Coding Plan 模型重定向与智能排序** - Kimi 渠道实现 Coding Plan 模型重定向与智能模型排序\n- **桌面端模型重定向区域 premium UI 重构** - 目标模型输入改为自定义下拉菜单,支持下拉建议\n- **快速模式与历史图片轮次限制配置** - Web 端补全快速模式与历史图片轮次限制配置\n- **流式断流超时预设快捷按钮** - 桌面端新增流式断流超时预设快捷按钮\n- **Esc 快捷键提示** - Web 和桌面端为对话框关闭按钮添加 Esc 快捷键提示\n- **Compatibility 分组高级开关补全** - Web 端和桌面端补全 Compatibility 分组所有缺失的高级开关\n- **桌面端 i18n 全面国际化** - 迁移到 vue-i18n + 外部 JSON 语言文件,桌面端渠道编辑、控制台、图表等全面 i18n 化\n- **文档: Geo SEO, llms.txt, README 桌面安装说明** - 新增 Geo SEO、llms.txt,改进贡献指南和 README 桌面安装说明\n\n### 修复\n\n- **临时限流不触发 Key 黑名单** - 临时限流场景不再误将 Key 加入黑名单\n- **REQUEST_TIMEOUT 默认值从 300s 调整为 120s** - 调整默认请求超时为更合理的 120 秒\n- **保留 web_search 工具以兼容 Codex v0.139.0+** - Responses 场景保留 web_search 工具兼容新版 Codex\n- **修复 UpstreamUpdate JSON 标签拼写错误** - 修正配置结构体 JSON tag 拼写\n- **图表系列修复** - 修复 KeyTrendChart yaxis 绑定、series 复用、forceNiceScale、失败率柱透明度等多项图表问题\n- **模型映射编辑修复** - 修复 modelMapping 对象值保存失败、combobox 值规范化、目标模型输入框抖动等问题\n- **限速配置修复** - 修复 0 值显示为空、窗口时长清空后无法保存、rateLimitWindowMinutes 字段遗漏等\n- **桌面端控制台组件修复** - 修复组件缺失导入、滚动导航、Esc 键 window 全局监听等\n- **i18n 修复** - 修复英文模式下统计图表中文、id.json 时长单位错误、useI18n setup 上下文时序等\n- **resumeChannel 后自动调用 setStatus('active')** - 恢复渠道后正确更新状态\n- **REQUEST_TIMEOUT/RESPONSE_HEADER_TIMEOUT 降级为启动兜底** - 超时配置运行时以调校台为准,环境变量仅作启动兜底\n\n### 重构\n\n- **渠道编辑器全面重构** - 左侧导航+右侧面板布局,拆分快速添加与编辑弹窗,统一内容顺序对齐 WebUI\n- **高级选项分组重构** - 创建 Runtime 运行期策略分组,统一布局结构,主动限速拆分为独立 RateLimitGroup\n- **模型映射区域深度重构** - 行内可编辑、视觉回退模型行内开关驱动,优化布局与样式\n- **快速添加面板重构** - 紧凑两栏布局、卡片式分块布局、上游类型选择器标题行后缀推荐项\n- **渠道卡片状态展示重构** - 统一交互逻辑、展开按钮与统计图表折叠优化\n- **熔断器/调校台滑块设计重构** - 参考 Web 端样式,增强可见性和对比度\n- **桌面端渠道编辑创建流程重构** - 统一使用 t 多语言函数,对齐内容顺序与 WebUI\n- **vue-i18n 迁移** - 从自定义 i18n 迁移到 vue-i18n + 外部 JSON 语言文件,统一两端 key 命名空间\n- **ChannelManager/ChannelTab 惰性加载** - 非活跃页跳过刷新,提升性能\n- **CircuitBreakerDialog 布局重构** - header/footer 固定,body 独立滚动\n\n### 文档\n\n- **新增 RESPONSE_HEADER_TIMEOUT 与运行时超时配置说明**\n- **更新 compactModel 说明,明确 Responses 渠道 failover 机制**\n\n## [v2.8.28] - 2026-06-11\n\n### 新增\n\n- **桌面端管理面板集成用量统计图表 (#199)** - 管理面板新增用量统计图表展示\n- **补齐渠道中心所有 provider 到 Agent 直连设置** - 完善 Agent 直连配置,覆盖所有渠道中心 provider\n- **驾驶舱 Override 有效期选择器补齐** - 驾驶舱 Override 配置界面新增有效期选择器\n- **upstream-check 新增功能与体验变更报告输出步骤** - 上游版本检查 skill 新增变更报告输出\n\n### 修复\n\n- **完善驾驶舱 i18n 并移除重复的系统状态显示** - 优化驾驶舱国际化文案,移除重复状态展示\n- **补全 tooltip content-class 并加大快捷键提示间距** - 修复 tooltip 样式类缺失,优化快捷键提示视觉间距\n- **补全 stream.go 中 message_delta 的 stop_details 字段** - 流式响应 handlers 补充 stop_details 字段处理\n- **添加 stop_details 字段到 message_delta** - providers 层新增 stop_details 字段支持\n- **过滤 compaction 流式响应中的 reasoning items (#179)** - Responses compaction 场景过滤推理内容,避免重复输出\n- **优化弹窗快捷键提示体验** - 改进弹窗快捷键提示交互\n- **Override 有效期标签改为用户易懂的「空闲自动恢复」** - 重写 Override 配置文案,提升用户理解\n- **有效期选择器默认选中 30min + 补充长时段选项** - 优化有效期默认值与选项范围\n- **修复磁贴图标透明背景与黄色光圈,补齐 Windows 磁贴资产** - 修复桌面端 Windows 图标透明度问题,补全磁贴资源\n- **千帆 Coding Plan 渠道补充模型映射与特性配置** - 完善千帆编码计划渠道模型映射\n- **火山方舟 Coding Plan 渠道补充模型映射与特性配置 (#204)** - 完善火山方舟编码计划渠道模型映射\n- **替换已归档官方镜像为社区维护 fork,修复 Docker API 版本兼容性问题** - Watchtower 迁移至社区维护镜像,修复 API 兼容性\n\n### 优化\n\n- **前端构建增加双层缓存,避免重复编译** - 前端构建流程新增缓存机制,加速构建\n\n### 改进\n\n- **驾驶舱 Override 有效期可配置** - 支持环境变量 `OVERRIDE_TTL_MINUTES`(默认 30 分钟)和前端下拉选择器(15min/30min/1h/2h/永不恢复)自定义 override 有效期\n- **Override Idle 续期** - 对话活跃时自动续期 override TTL,仅在闲置超过设定时间后才恢复默认调度\n- **Override 永不恢复选项** - 支持设置 override 永不过期,手动恢复前不会自动过期\n\n### 文档\n\n- **注释说明极易云 originrouter 刻意不纳入 Agent 直连** - 渠道预设注释补充设计决策说明\n- **新增 TODO #199 桌面端用量显示与 #179 compaction v2 think 拆分问题** - 更新待办事项\n- **完成 TODO #1 OpenRouter 免费路由工具调用排查,确认为上游限制** - 标记已完成任务\n- **清理已完成任务并添加三个新 TODO** - 维护任务列表\n\n### 变更文件\n\n- `backend-go/internal/conversation/override.go` - IsPerpetual、per-call duration、RefreshOverrideForUser、SetDefaultTTL\n- `backend-go/internal/conversation/override_test.go` - 新增 8 个测试用例\n- `backend-go/internal/config/env.go` - 新增 OVERRIDE_TTL_MINUTES 环境变量\n- `backend-go/main.go` - 读取环境变量替换硬编码 30 分钟\n- `backend-go/internal/handlers/conversation_handler.go` - API body 新增 duration 参数,响应新增 isPerpetual\n- `backend-go/internal/scheduler/channel_scheduler.go` - SelectChannel 成功消费 override 时调用 RefreshOverrideForUser\n- `frontend/src/components/ConversationDashboard.vue` - 新增 override 持续时间下拉选择器\n- `frontend/src/components/ConversationCard.vue` - 永久 override 展示逻辑\n- `frontend/src/services/api-types.ts` - SequenceOverrideInfo 新增 isPerpetual\n- `frontend/src/services/api.ts` - setConversationOverride 新增 duration 参数\n- `frontend/src/i18n/messages-{zh-cn,en,id}.ts` - 新增 4 个 i18n key\n\n## [v2.8.27] - 2026-06-10\n\n### 新增\n\n- **桌面端 MiniMax M3 渠道预设** - 新增 MiniMax M3 channel presets\n- **桌面端讯飞星辰 MAAS 直连支持** - 添加讯飞星辰 MAAS 桌面端 Agent 直连支持\n- **弹窗快捷键提示与确认交互** - 所有弹窗添加 ESC 取消与 Cmd/Ctrl+Enter 确认快捷键提示,提升操作效率\n\n### 修复\n\n- **Responses serviceType claude 路径认证修复** - serviceType claude 路径正确使用 x-api-key 认证头\n- **桌面端 OpenCode 讯飞星辰直连精确移除** - 仅移除 OpenCode 的讯飞星辰直连,保留 Claude/Codex 路径\n- **Fable reasoning 迁移补齐 model mapping** - migrate fable reasoning mapping with model mapping\n- **REWRITE_RESPONSE_MODEL 支持 Responses API** - Responses API 场景正确支持 REWRITE_RESPONSE_MODEL 重写\n\n### 重构\n\n- **统一 codex responses 预设 source key** - 统一 codex responses 预设 source key 为 codex/gpt/mini\n\n### 其他\n\n- **Responses provider 认证头 serviceType 分支测试** - 添加 Responses provider 认证头 serviceType 分支测试用例\n\n## [v2.8.26] - 2026-06-10\n\n### 新增\n\n- **Claude Fable 模型支持** - 新增 Claude Fable 系列模型映射与兼容处理,升级时自动补齐 Fable 模型映射配置\n- **渠道级主动限速与上游 rate limit header 自动追踪 (#190)** - 新增渠道级令牌桶限速、并发控制与 cooldown 机制,自动解析上游 `X-RateLimit-*` / `Retry-After` 响应头并动态调整限速参数,MiMo 默认 RPM=80\n- **历史图片轮次限制与占位符替换** - 新增渠道级历史图片轮次限制配置(`historicalImageTurnLimit`),超过指定轮次的历史对话图片自动替换为 `[Image]` 占位符,避免不必要的 vision 回退模型切换。覆盖 Claude Messages、OpenAI Chat、Responses API、Gemini 四种协议格式,Web/桌面渠道编辑 UI 支持按渠道配置\n- **渠道体验增强与 Messages 渠道级 CCH 开关** - Web/桌面渠道列表新增 15 分钟窗口”缓存写偏高”提醒 badge;桌面端 RunAPI messages 预设默认关闭 `normalizeMetadataUserId`;`stripBillingHeader` 从全局设置下沉为 messages 渠道级开关,默认关闭,并同步补齐 Web/桌面渠道编辑表单与老配置迁移逻辑\n- **桌面端 OpenRouter 与 ModelScope 渠道预设** - 新增 OpenRouter、ModelScope 渠道中心预设,并将 Kimi Code Plan 合并入 Kimi 预设作为编码计划条目,添加到 Claude/Codex/OpenCode 直连配置列表\n- **桌面端渠道预设扩充** - 新增 RunAPI 赞助商预设、Tencent TokenHub 预设与国内 coding-plan 网关预设,统一 RunAPI 与 Token Plan 文案,优化 RunAPI 渠道预设排序\n- **桌面端管理面板 Fuzzy 开关与熔断器配置** - 管理面板工具栏新增 Fuzzy 模式开关和熔断器配置按钮,熔断器配置预设值、slider 范围与 WebUI 同步对齐\n- **桌面端 Codex/Responses 分层排障诊断** - 在桌面 Agent 页 Codex 卡片新增分层排障,按顺序检查”Codex 配置一致性 → Responses 渠道可用性 → 最近失败请求”,帮助定位 #156 类”本地网关可达但请求失败”的问题。后端扩展 `AgentConfigStatus`(新增 `authMode`/`configConsistent`/`diagnosticCode`/`diagnosticMessage`),在 `getCodexStatus` 中识别 CCS 等工具导致的 `config.toml` 与 `auth.json` 不一致(缺 key、auth_mode 不匹配、插件模式缺 bearer token、旧式配置残缺、配置污染等);前端新增 `useResponsesDiagnostics` composable,聚合 Codex 配置层、Responses 渠道层(无渠道/全禁用/无 key/熔断/协议错配)与最近失败日志层(401/403、429、5xx、超时)的诊断结论,并在状态页提供最高优先级问题摘要。第一版仅给出诊断与手动修复建议,不做自动修复。涉及 `desktop/internal/configservice/service.go`、`desktop/frontend/src/composables/useResponsesDiagnostics.ts`、`desktop/frontend/src/components/agent/*`、`desktop/frontend/src/components/status/StatusTab.vue`、`desktop/frontend/src/i18n/messages.ts`、`desktop/frontend/src/types/index.ts`\n- **桌面端 dashboard 显示运行态当前渠道** - 桌面端 dashboard 新增运行态当前渠道信息展示\n- **简化前端添加渠道流程** - 优化前端渠道添加交互流程\n\n### 修复\n\n- **桌面端 Codex 排障忽略 UI 选择的目标模式** - 排障诊断只检查 config.toml / auth.json 文件自洽性,未考虑用户在 UI 上选择的 Codex 模式(快捷/插件),导致选了快捷模式但磁盘仍是插件模式时误报”配置一致”。修复后前端排障会比较 `status.mode`(磁盘实际模式)与 `codexMode`(UI 目标模式),不一致时优先报模式冲突并提示重新应用。涉及 `desktop/frontend/src/composables/useResponsesDiagnostics.ts`、`desktop/frontend/src/i18n/messages.ts`\n- **桌面端 Codex 排障按需触发** - Codex 排障诊断改为按需触发,避免不必要的自动诊断开销\n- **桌面端渠道介绍 i18n 对齐** - 补齐渠道介绍缺失的 key 与中文产品描述\n- **桌面端 Fuzzy 模式 API 路径补全** - Fuzzy 模式 API 路径自动补全 `/api` 前缀并改为 watch 驱动加载\n- **桌面端熔断器配置预设值同步** - 同步熔断器配置预设值、slider 范围与 WebUI 一致\n- **桌面端 fetch 网络层错误包装** - 包装 fetch 网络层错误,避免管理面板显示原始 `Failed to fetch` 信息\n- **桌面端切换渠道目标时清除成功提示条** - 修复切换渠道目标时残留上一目标操作结果的问题\n- **桌面端补齐 claudeProviderKeys 缺失键** - 补齐 claudeProviderKeys 缺失的 openrouter/modelscope 键,修复 TS2345 类型错误\n- **桌面端当前渠道显示补上渠道名称** - 修复当前渠道显示缺失渠道名称的问题\n- **桌面端渠道预设描述统一** - 以平台身份重写渠道预设描述,统一 MiniMax M3 旗舰描述文案\n- **桌面端 agent 配置绑定同步** - 同步 agent 配置绑定关系\n- **按 content block index 管理 tool_use 缓冲** - 修复重叠 tool_use 导致 `Content block not found` 错误\n- **preflight 对未知 SSE 事件类型增加内容兜底识别** - Responses preflight 阶段对未知 SSE 事件类型增加内容兜底识别逻辑\n- **识别 messages→responses 桥接下的上游错误** - 修复 messages→responses 桥接场景下上游错误/失败/reasoning 事件未被正确识别的问题\n- **调整熔断器预设超时值** - 均衡档改为 60s/60s/180s\n- **渠道级流式超时预设与全局调校台对齐** - 渠道级流式超时预设值与全局调校台保持一致\n- **补齐渠道调校台参数同步** - 修复渠道级调校台参数未与全局配置同步的问题\n- **优化 RunAPI 渠道预设与排序** - 优化 RunAPI 渠道预设配置与列表排序\n- **tune 工具调用空闲处理** - 调整流式工具调用空闲超时参数\n- **加固本地 compact prompt** - 加固 Responses compact 本地提示词\n\n### 重构\n\n- **合并历史图片轮次限制到熔断器对话框** - 将历史图片轮次限制配置合并到熔断器对话框中,减少工具栏按钮数量\n- **从 EnvTab 移除熔断器配置卡片** - 从 EnvTab 移除熔断器配置卡片,统一由管理面板弹窗管理\n- **熔断器设置更名为调校台 Tuning Bench** - 将熔断器设置相关 UI 文案统一更名为”调校台 Tuning Bench”\n\n### 文档\n\n- **上游评估 TODO 更新** - 添加上游评估待办事项,记录 #190 新特性条目与上游协议变更\n- **upstream-check TODO 格式对齐** - 对齐上游检查脚本 TODO 分组标题层级与任务格式\n\n### 其他\n\n- **将上游检查脚本迁入技能目录** - 将上游版本检查脚本从 tools/ 迁入技能目录,优化技能目录结构\n\n## [v2.8.25] - 2026-06-07\n\n### 新增\n\n- **RunAPI 赞助商与桌面端直连预设** - 新增 RunAPI 赞助商展示、渠道中心预设与 Claude/Codex/OpenCode 直连配置,支持 Messages、Chat、Responses 三类目标\n- **流式 post-commit 阶段畸形工具调用截断策略** - 文本已输出后遇到畸形 `tool_use` 时缓冲并截断工具调用,注入 `end_turn` 与 `message_stop` 正常结束,避免客户端进入无效 `tool_result` 重试\n- **Responses 添加 tool_search 工具类型识别与兼容处理** - 新增 tool_search 工具类型的识别与兼容处理逻辑\n- **新增 stripImageGenerationTool 渠道开关** - 渠道配置新增 stripImageGenerationTool 开关,支持过滤图片生成工具\n- **桌面端同步 stripImageGenerationTool 渠道开关** - 桌面端编辑渠道界面同步支持 stripImageGenerationTool 开关\n- **上游版本每日自动检查 skill 与脚本** - 添加上游版本每日自动检查 skill 与脚本\n- **渠道管理界面增强与国际化完善** - Web UI 渠道管理界面增强与国际化完善\n- **新增流式响应观察者模式** - 流式响应新增观察者模式,支持可插拔的流式处理逻辑\n- **上游请求日志接入请求上下文标签** - 为上游请求日志接入 session/round 请求上下文标签\n- **新增请求日志上下文模块** - 新增请求日志上下文模块支持 session/round 标签\n- **流式响应 TPS 进度记录** - 记录流式响应 TPS 进度\n- **流式 200 伪成功检测与 failover 机制** - 新增流式 200 伪成功检测与 failover 机制\n- **Desktop 前端熔断器配置新增流式超时滑块** - 桌面端熔断器配置界面新增流式超时滑块\n- **Web 前端熔断器配置新增流式超时滑块** - Web 前端熔断器配置界面新增流式超时滑块\n- **Gemini/Images 流式 preflight 适配** - 完成 Gemini/Images 流式 preflight 适配\n- **Gemini handler 初步接入流式 preflight 检测** - Gemini handler 初步接入流式 preflight 检测\n- **Responses handler 流式两阶段 preflight 检测** - Responses handler 接入流式两阶段 preflight 检测\n- **Chat handler 流式两阶段 preflight 检测** - Chat handler 接入流式两阶段 preflight 检测\n- **Minimax 2.7 思考标签处理升级** - 升级 Minimax 2.7 思考标签处理为协议级提取与原生推理字段转换\n\n### 修复\n\n- **记录缓冲工具事件的原始流日志** - 修复 tool_use 缓冲期间原始 SSE 事件未写入响应日志的问题,确保工具开始、参数增量与结束事件可追踪\n- **渠道中心预设顺序与 RunAPI Responses 文案优化** - 保持后端预设顺序避免按 Key 分组导致列表跳位,并为 RunAPI Responses 目标显示原生 Responses 方案文案\n- **桌面端切换渠道时清除成功提示与错误状态** - 修复切换渠道时旧的成功提示和错误状态残留的问题\n- **修复三个疑似 bug (#162 #187 #188)** - 修复三个疑似 bug\n- **延长流式空闲超时限制** - 延长 stream idle timeout limits\n- **保留流式 preflight 诊断信息** - 保留 stream preflight diagnostics\n- **修复 Windows 图标透明度并补全 Codex 预设模板** - 修复桌面端 Windows 图标透明度并补全 Codex 预设模板\n- **添加 guide/ 重定向文件修复 GitHub 链接 404** - 修复 docs/guide 重定向导致的 GitHub 链接 404\n- **将完整工具调用视为非空流** - 修复完整工具调用被误判为空流的问题\n- **修复 JSON 日志截断破坏 Unicode 字符** - 修复 JSON 日志截断时破坏 Unicode 字符的问题\n- **修复跨协议能力测试虚拟协议检测** - 修复跨协议能力测试中虚拟协议检测逻辑\n- **修正 MSIX 数据目录显示路径** - 修正桌面端 MSIX 数据目录显示路径\n- **流式工具调用使用 idle timeout** - 修复流式工具调用使用正确的 idle timeout\n- **分离工具调用流式超时** - 将工具调用流式超时与普通流式超时分离\n- **修复 compact 流式转换状态 panic** - 修复 Responses compact 流式转换状态 panic\n- **捕捉已提交流式响应断流** - 修复已提交流式响应断流未被捕捉的问题\n- **移除流式超时 off 档并为渠道新增超时覆盖开关** - 移除流式超时 off 档并为渠道新增超时覆盖开关\n- **修复工具调用流式 preflight 误判** - 修复 Chat 工具调用流式 preflight 误判\n- **修复 restoreApiKey 接口请求路径** - 修复桌面端 restoreApiKey 接口请求路径\n- **修复编辑渠道对话框拉黑密钥恢复按钮不可点击** - 修复桌面端编辑渠道对话框拉黑密钥恢复按钮不可点击\n- **修复编辑渠道对话框拉黑密钥恢复按钮不可点击 (Web)** - 修复 Web 端编辑渠道对话框拉黑密钥恢复按钮不可点击\n- **规范化 compact reasoning 输出** - 修复 Responses compact reasoning 输出格式\n- **修复频道菜单缺失 i18n** - 修复桌面端频道菜单缺失 i18n 翻译\n- **对齐管理面板协议显示名** - 修复桌面端管理面板协议显示名不一致\n- **修复 Minimax 2.7 模型 think 标签问题** - 修复 minimax 2.7 模型有 `` 标签的问题\n\n### 重构\n\n- **按语言拆分 i18n 消息文件** - 前端 i18n 消息文件按语言拆分\n- **拆分 API 服务层类型和工具** - 前端 API 服务层类型和工具拆分\n- **移除 Wails v3 系统通知服务** - 桌面端移除 Wails v3 系统通知服务\n\n### 测试\n\n- **补充 RunAPI 桌面端预设与 Agent 配置回归测试** - 覆盖 RunAPI Messages/Chat/Responses 渠道 payload、官网链接、Claude 状态识别与 Key 复用、Codex 快捷/插件模式、OpenCode 直连配置\n- **GPT 类渠道探测模型覆盖 codex-auto-review** - GPT 类渠道探测模型新增 codex-auto-review 覆盖\n- **覆盖无模型映射的协议转换测试组** - 新增无模型映射的协议转换测试组覆盖\n- **拆分 chat_to_responses 测试文件** - 拆分 converters chat_to_responses 测试文件\n- **拆分 configservice 测试文件** - 拆分桌面端 configservice 测试文件\n- **拆分 failover 测试文件** - 拆分 common failover 测试文件\n\n### 文档\n\n- **更新 TODO 格式规范与疑似 bug 修复记录** - 更新 TODO 格式规范与疑似 bug 修复记录\n- **更新 TODO 完成状态与关键提交记录** - 更新 TODO 完成状态与关键提交记录\n- **添加 GPT 类渠道模型测试覆盖待办** - 添加 GPT 类渠道模型测试覆盖待办\n- **新增桌面端渠道成功提示清理待办** - 新增桌面端渠道成功提示清理待办\n- **精简历史版本记录** - 精简 CHANGELOG 历史版本记录\n\n### 其他\n\n- **新增 Serena MCP 配置与项目记忆** - 新增 Serena MCP 配置与项目记忆\n- **升级 Vite 和 Vitest** - 前端升级 Vite 和 Vitest 依赖\n- **合并 guide/ 重定向文件修复** - 合并 guide/ 重定向文件修复分支\n\n## [v2.8.24] - 2026-06-04\n\n### 修复\n\n- **修复深层嵌套图片检测遗漏导致视觉回退失效** - 修复嵌套结构中图片 URL 未被识别,导致视觉回退策略无法生效的问题\n\n## [v2.8.23] - 2026-06-04\n\n## [v2.8.22] - 2026-06-04\n\n### 修复\n\n- **修复桌面端管理面板 CORS 认证失败** - 修复 `OPTIONS` 预检请求被认证中间件拦截返回 401,导致桌面端 WebView 无法访问管理接口的问题;`ENABLE_CORS=false` 时仍为带 `Origin` 的请求注入 CORS 响应头\n- **version-bump 新增 CHANGELOG 覆盖率校验** - 版本升级流程新增 CHANGELOG 覆盖率校验,防止遗漏提交记录\n\n### 文档\n\n- **补全 v2.8.21 CHANGELOG** - 补全 v2.8.21 版本遗漏的 CHANGELOG 条目\n- **新增 Windows 桌面图标透明度待办项** - 新增 Windows 桌面图标透明度相关的待办任务\n\n## [v2.8.21] - 2026-06-04\n\n### 新增\n\n- **桌面端原生管理控制台** - 桌面端原生管理控制台替代 iframe WebUI,完整覆盖 i18n,管理入口统一命名为 Dashboard\n- **桌面端渠道管理重构** - 添加渠道简化为快速粘贴模式(支持 Cmd/Ctrl+Enter 创建),编辑渠道弹窗对齐 WebUI 核心交互,新增预设模板、模型拉取、高级选项分组、Base URL 预览、服务类型动态排序、模型映射协议预置列表与 filter chips、API Key 必填校验\n- **桌面端渠道操作增强** - 渠道卡片支持置顶置底并增加删除守卫,渠道日志弹窗重构为可展开列表并支持自动刷新,删除渠道时显示 loading 遮罩,模型重定向 target 输入框始终提供候选\n- **桌面端会话驾驶舱重构** - 会话驾驶舱重构为瀑布流卡片并对齐 WebUI cockpit,能力测试对话框对齐 WebUI,能力测试多 job 并发轮询修复\n- **桌面端 Codex 会话迁移** - 添加 Codex 会话迁移支持,快捷/插件模式切换对齐 WebUI\n- **CLI --backupdir 选项** - 新增 `--backupdir` 选项指定配置备份目录\n- **CLI 自定义运行时路径** - 支持自定义运行时路径\n- **LOG_DIR=none 禁用日志文件** - 支持 `LOG_DIR=none` 环境变量禁用日志文件写入\n- **渠道级请求超时配置** - 后端渠道配置新增 `requestTimeoutMs`,非流式上游请求优先使用渠道超时,未设置或为 0 时继承全局 `REQUEST_TIMEOUT`\n\n### 修复\n\n- **messages**: system 角色归一化对所有上游生效\n- **日志 store 改用 metricsKey 分桶** - 根治删除渠道后日志串桶问题\n- **模型过滤拒绝非法字符** - 模型过滤拒绝顿号等非法字符并按分隔符拆分\n- **余额不足/套餐过期自动拉黑** - 补全 403 关键词自动拉黑,改为双词组合匹配覆盖更多上游变体\n- **下架已停用 GPT 模型** - 从能力测试候选、模型重定向候选和预设中移除 `gpt-5.2` / `gpt-5.2-codex` / `gpt-5.3-codex`,haiku 映射改为 `gpt-5.4-mini`\n- **桌面端编辑渠道大量 UI 修复** - 快捷键与底部操作栏、Esc/Enter 快捷键、窄窗口布局、高级选项平铺 Runtime/Transport 分组、Website 移入连接分组、模型卡片布局、noVisionModels 合并行级 toggle、思考强度/Text verbosity 下拉修复、popover 主题变量补全、移除冗余 Latency 列与重复快捷键提示、保存快捷键统一为 Cmd/Ctrl+Enter\n- **桌面端快速粘贴优化** - Textarea 高度修正、自动应用识别结果、自动补全 name/serviceType、按钮提示显示平台对应修饰符、移除 Service Type 识别卡片\n- **桌面端 Dashboard 修复** - 移除轮询可见性检查修复渠道列表不自动刷新、管理面板 Tabs 配色对齐深色主题、备用池文案对齐 WebUI、驾驶舱命名对齐并移除手动刷新、网关监控按钮改为「打开管理面板」\n- **桌面端模型列表修复** - 拉取补传 key 参数修复 'No API key provided' 错误、源模型列表对齐 WebUI allSourceModelOptions、预设数据与按钮分组完全对齐 WebUI\n- **桌面端移除一键配置已下线模板** - 移除 gpt-5.3/gpt-5.2 模板,haiku 改映射到 gpt-5.4-mini\n- **桌面端渠道中心恢复原有 ChannelTab 界面** - 移除渠道测速按钮,补齐编辑弹窗快捷键\n- **桌面端 i18n 补全** - 编辑渠道界面、编辑弹窗新增 i18n keys 等缺失翻译补全\n\n### 重构\n\n- **桌面端管理入口命名统一** - 管理入口统一命名为 Dashboard,隐藏批量测速按钮,移除 Codex 分组冗余开关\n- **桌面端编辑渠道布局对齐** - 请求超时从基础信息移入 Runtime 分组,抽取渠道草稿保存逻辑统一提交路径\n- **渠道管理清理** - 移除未使用代码(开启 noUnusedLocals)、删除渠道编辑弹窗冗余元素\n\n### 文档\n\n- **Docker 部署示例修正** - 修正 Docker 部署示例与实际 compose 保持一致\n- **LOG_DIR=none 补充文档** - 补充 `LOG_DIR=none` 禁用日志文件文档及测试用例\n- **新增 TODO 待办任务文档**\n\n## [v2.8.20] - 2026-06-01\n\n### 新增\n\n- **渠道级请求超时配置** - 后端渠道配置新增 `requestTimeoutMs`,非流式上游请求优先使用渠道超时,未设置或为 0 时继承全局 `REQUEST_TIMEOUT`;五类渠道新增/更新路径均校验负数并支持清空回退。\n- **Web UI 渠道请求超时表单** - 渠道编辑表单新增“请求超时 (ms)”输入项,支持编辑态清空恢复继承全局,并在复制渠道、payload 序列化、差异检测和多语言文案中同步该字段。\n\n### 新增\n\n- **桌面端 Codex Agent 配置双模式** - Codex 支持两种 CCX 模式:快捷模式(OpenAI provider + CCX 代理,不迁移会话、不支持插件)和插件模式(CCX 原生 provider + `requires_openai_auth = true`,支持插件但切换需会话迁移提示)。UI 下拉框拆分为两个选项,互切时显示会话迁移警告。\n- **Codex 配置改用快捷/插件模式开关** - 桌面端 Codex 配置入口统一改为快捷模式/插件模式开关,明确不同接入路径。\n- **Codex provider 块统一使用 `env_key`** - 将 `[model_providers.xxx]` 块中的 `temp_env_key` 统一替换为官方文档字段 `env_key`。\n- **桌面端 AgentCard 编辑器打开支持多编辑器选择** - 配置文件和认证文件的\"用编辑器打开\"按钮现在支持多编辑器场景,通过透明 select 覆盖层提供编辑器选择下拉。\n- **渠道中心已保存 key 的 preset 优先排列** - 桌面端渠道中心将已保存 API Key 的 preset 优先展示,便于快速选择常用渠道。\n- **已配置 key 的渠道在渠道中心优先展示** - Web 前端渠道中心根据已配置 Key 状态优先排列渠道。\n- **Codex 渠道模板新增 codex-auto-review 重定向** - 渠道模板新增 codex-auto-review 重定向能力。\n- **新增 WebUI 新用户指引** - WebUI 增加新用户引导说明,帮助首次使用者快速理解渠道配置流程。\n- **OpenAI 直连增加 API Key 勾选框** - 桌面端 OpenAI 直连新增「我有自己的 API Key」勾选框。\n- **新用户指引添加渠道展示模拟表单** - 新用户指引增加渠道展示模拟表单,更直观展示快速模式字段。\n- **Codex 第三方 provider 支持快捷/插件模式切换** - 第三方 Codex provider 支持在快捷模式与插件模式之间切换。\n\n### 修复\n\n- **下架已停用 GPT 模型** - 从能力测试候选、模型重定向候选和预设中移除 `gpt-5.2` / `gpt-5.2-codex` / `gpt-5.3-codex`,haiku 层模型统一替换为 `gpt-5.4-mini`,并为桌面端渠道模板补充旧模型回归检查。\n\n- **浏览器直达移除 `ccx_desktop` 参数** - 修复浏览器直达 WebUI 时残留桌面端参数的问题。\n- **默认语言改为中文并增加浏览器语言检测** - 修复默认语言选择逻辑,新增浏览器语言检测。\n- **新用户指引协议切换说明拆分为三段** - 调整新用户指引中协议切换说明的展示结构。\n- **新用户指引简化标签列表** - 将新用户指引标签列表简化为 Claude / Codex / Gemini 等。\n- **新用户指引示例渠道改为 gpt-5.5 中转** - 更新新用户指引中的示例渠道文案。\n- **新用户指引删除 quickHint** - 删除 quickHint,改为说明 CCX 自动识别。\n- **删除新用户指引模拟表单及关联资源** - 删除模拟表单及其关联 i18n/CSS 资源,简化引导内容。\n- **新用户指引添加渠道说明拆分为两段** - 调整添加渠道说明,使内容层次更清晰。\n- **渠道置顶后保持渠道顺序** - 修复渠道置顶后顺序被打乱的问题。\n- **修正 Codex 认证配置模式** - 修复 Codex 认证配置模式写入与展示不一致的问题。\n- **渠道排序以 priority 优先,hasKey 仅作兜底** - 修复渠道排序中 hasKey 权重过高导致 priority 失效的问题。\n- **消除 Codex 非 OpenAI provider 时悬空分隔符** - 修复 Codex 非 OpenAI provider 表单中多余竖线分隔符。\n- **调整 Linux 数据目录到 XDG state** - 修复 Linux 桌面端数据目录位置,改用 XDG state 路径。\n- **掩码 Codex 配置预览密钥** - 修复 Codex 配置预览中密钥未正确脱敏的问题。\n- **修正 Codex `auth_mode` 为 `apikey`** - 修正 Codex 配置中的认证模式字段值。\n- **OpenAI 直连取消勾选 API Key 时清空残留值** - 修复 OpenAI 直连关闭自带 Key 后仍保留旧值的问题。\n- **修正 Codex 配置预览 `auth_mode` 被误掩码** - 修复配置预览中普通字段被误判为密钥的问题。\n- **对齐 Codex 插件模式认证配置** - 修复 Codex 插件模式下认证配置与预期不一致的问题。\n- **JSON 配置 diff 改为字段级掩码** - 修复 JSON 配置 diff 脱敏粒度过粗导致预览不准确的问题。\n- **修正短密钥掩码后 JSON diff 漏报变更** - 修复短密钥脱敏后 JSON diff 无法识别真实变更的问题。\n- **按字段掩码 Codex 配置 diff** - Codex 配置 diff 改为按字段掩码,避免误掩码普通配置项。\n- **OpenAI 直连自带 Key 必须输入** - 修复 OpenAI 直连勾选自带 Key 时允许空值的问题。\n\n### 重构\n\n- **移除左上角 Logo 点击跳转 GitHub 链接** - 移除 UI 左上角 Logo 的 GitHub 跳转行为。\n- **配置预览文件标签改用增删行数统计** - 桌面端配置预览文件标签改为展示增删行数统计。\n\n### 文档\n\n- **更新桌面安装文档** - 更新桌面安装文档,补充 Windows Store 已上架和 Homebrew 安装说明。\n- **排障指南添加各入口 curl 快速验证命令** - 为排障指南补充各协议入口的 curl 快速验证命令。\n- **排障指南首条添加关闭全局代理和 TUN** - 排障指南首条新增关闭全局代理和 TUN 的排查建议。\n\n### 其他\n\n- **修复 go fmt 格式化缩进** - 修正 Go 格式化后的缩进样式。\n- **移除 Claude-Reasoning-Debug 调试日志** - 移除 Claude-Reasoning-Debug 调试日志及 summarizeAssistantReasoningState 函数。\n- **精简新用户指引内容** - 移除新用户指引中的状态图例、开关和接入客户端三节。\n- **补充 desktop type-check 脚本** - 为桌面前端补充 type-check 脚本。\n- **固化 Wails3 CLI 安装版本** - CI 中固定 Wails3 CLI 安装版本,提升构建稳定性。\n\n## [v2.8.19] - 2026-05-30\n\n### 修复\n\n- **补全 MiMo target 描述删除遗漏** - 补全桌面端 MiMo target 描述删除时的遗漏处理。\n\n## [v2.8.18] - 2026-05-30\n\n### 新增\n\n- **按 ReasoningParamStyle 转换客户端原始 reasoning 参数** - 新增按 ReasoningParamStyle 配置项转换客户端原始 reasoning 参数的能力。\n- **Passthrough 路径 ReasoningMapping 分支补充 reasoning_effort 样式处理** - 在 passthrough 路径的 ReasoningMapping 分支中补充 reasoning_effort 样式的处理逻辑。\n- **桌面端 add Homebrew tap 自动化** - 新增 Homebrew tap 自动化发布流程。\n- **桌面端点击版本号触发手动更新检查并计入冷却时间** - 点击版本号可手动触发更新检查,并计入冷却时间避免频繁检查。\n- **桌面端 Codex CCX 代理切换为 openai_base_url 官方模式** - Codex CCX 代理从自定义模式切换为 openai_base_url 官方模式。\n\n### 修复\n\n- **渠道中心布局断点降低至 md 并修复窄屏 URL 溢出** - 修复渠道中心在窄屏下的布局问题,将断点降低至 md 并修复 URL 文本溢出。\n- **Responses API 返回 426 让 Codex WebSocket 回退到 HTTP POST** - 修复 Responses API 返回 426 状态码时 Codex WebSocket 正确回退到 HTTP POST。\n- **修复断裂文档链接,统一 PORT=3688,补充源码构建前置依赖** - 修复文档中断裂的链接,统一 PORT 配置为 3688,补充源码构建的前置依赖说明。\n- **MiMo messages 目标描述统一为通用表述** - 统一桌面端 MiMo messages 渠道目标描述为通用表述。\n\n### 重构\n\n- **移除熔断器弹窗标题图标** - 移除熔断器弹窗中的标题图标,简化界面。\n- **渠道中心添加目标标签改为产品名称** - 将渠道中心的添加目标标签改为显示产品名称。\n\n### 其他\n\n- **UI 熔断器配置改为预设按钮+滑块交互** - 将熔断器配置改为预设按钮+滑块的交互方式,删除重复的 .env 分组。\n- **UI 熔断器参数三列并排布局+间隔线** - 调整熔断器参数为三列并排布局并增加间隔线。\n- **UI 预设按钮移到滑块下方,参数列增加内边距** - 将预设按钮移到滑块下方,参数列增加内边距优化间距。\n\n## [v2.8.17] - 2026-05-29\n\n### 新增\n\n- **新增 gpt-5.4-mini 到 codex responses 协议源模型列表** - 前端 codex responses 协议源模型列表新增 gpt-5.4-mini 模型。\n- **更新 Gemini 源模型列表** - 更新前端 Gemini 源模型列表配置。\n- **更新模型列表并同步前后端** - 更新模型列表并保持前后端配置同步。\n\n### 修复\n\n- **WebUI iframe 认证使用 ADMIN_ACCESS_KEY 而非 PROXY_ACCESS_KEY** - 修复桌面端 WebUI iframe 认证使用错误密钥的问题。\n\n### 其他\n\n- **简化 favicon,替换为极简 CX 描边图标** - 简化 favicon 设计,替换为极简 CX 描边图标。\n\n## [v2.8.16] - 2026-05-29\n\n### 新增\n\n- **添加 DashScope Token Plan 预设** - 桌面端添加 DashScope Token Plan 预设。\n\n### 修复\n\n- **修正渠道一键预设显示** - 修正 UI 渠道一键预设显示问题。\n- **复用 Windows 安装器旧安装目录** - 桌面端 Windows 安装器复用旧安装目录。\n- **修正 Web Logo 主题配色** - 修正 Web Logo 在不同主题下的配色显示。\n- **修正暗色模式 Logo 配色** - 修正桌面端暗色模式下 Logo 配色。\n- **修正图标亮暗主题显示** - 修正 UI 图标在亮暗主题下的显示。\n- **隐藏桌面 WebUI 版本检测** - 隐藏桌面端内嵌 WebUI 版本检测。\n\n### 其他\n\n- **隔离 backend 端口选择测试避免读取真实 .env** - 隔离桌面端 backend 端口选择测试,避免读取真实 `.env`。\n\n## [v2.8.15] - 2026-05-29\n\n### 新增\n\n- **重设计应用图标与品牌 Logo** - 桌面端应用图标与品牌 Logo 全新设计。\n\n### 修复\n\n- **统一 BuildChannelView 高级开关字段输出** - 将 `normalizeSystemRoleToTopLevel`、`stripEmptyTextBlocks`、`passbackReasoningContent`、`passbackThinkingBlocks`、`injectDummyThoughtSignature`、`stripThoughtSignature` 六个高级开关字段统一收归 `BuildChannelView()` 公共函数输出,消除 messages/chat/responses/gemini 四个 handler 中的重复补丁,确保所有渠道类型的 WebUI 编辑回显与保存一致。\n- **移除 PreviewAgentConfigDiff 未使用的 status 变量** - 清理桌面端 PreviewAgentConfigDiff 组件中未使用的 status 变量。\n- **Agent 配置应用使用最新 .env 端口** - 桌面端 Agent 配置应用时使用最新的 .env 端口值。\n- **统一 Web UI Logo 与应用图标** - 统一 Web UI 中的 Logo 和应用图标,保持视觉一致性。\n- **补齐渠道一键模板配置** - 前端补齐渠道一键模板配置功能。\n- **调整 Opus 4.8 能力探测请求** - 调整 Claude Opus 4.8 模型的能力探测请求参数。\n- **修复预设渠道控制台网址和套餐显示** - 修复预设渠道中控制台网址和套餐信息显示不正确的问题。\n\n## [v2.8.14] - 2026-05-29\n\n### 新增\n\n- **新增 normalizeSystemRoleToTopLevel 开关兼容旧 Claude 上游** - 新增系统角色规范化到顶层字段的兼容开关,便于旧版 Claude 上游适配。\n- **统一桌面端与 Web UI 的 MiMo/DeepSeek 渠道预设配置** - 统一桌面端与 Web UI 的 MiMo、DeepSeek 渠道预设,保持跨入口配置一致。\n- **补全渠道中心全部预设的中英文 i18n** - 为渠道中心全部预设补齐中英文国际化文案。\n- **主题切换改为自动/亮色/暗色三按钮,默认跟随系统** - 桌面端主题切换调整为自动、亮色、暗色三按钮,并默认跟随系统主题。\n\n### 修复\n\n- **修复 Messages 嵌套工具结果图片未触发视觉回退** - 识别 `tool_result.content` 等嵌套 content 数组中的图片块,确保 MiMo 等配置 `visionFallbackModel` 的渠道在历史截图请求中正确切换到识图回退模型,并跳过 `noVision` 渠道。\n- **调整主题选项排序** - 调整桌面端主题选项展示顺序,使默认与常用选项更符合使用习惯。\n- **修复中文模式下渠道说明显示英文** - 修复中文界面中渠道说明仍显示英文的问题。\n- **修复渠道 index 为 0 时能力测试无法启动** - 修复前端能力测试在渠道 index 为 0 时被误判而无法启动的问题。\n- **修复桌面端内嵌 Web UI 能力测试无响应的问题** - 修复桌面端内嵌 Web UI 发起能力测试后无响应的问题。\n- **修复亮色模式下所有状态色文字对比度不足** - 调整亮色模式状态色文本样式,提升可读性。\n\n### 重构\n\n- **移除浏览器模式的 Wails mock 传输层** - 移除桌面端浏览器模式下的 Wails mock 传输层,简化开发模式通信链路。\n\n## [v2.8.13] - 2026-05-29\n\n### 新增\n\n- **添加 claude-opus-4-8 模型到能力探测与优先级列表** - 前端能力探测与模型优先级排序规则新增 claude-opus-4-8 模型支持。\n- **桌面端亮暗色主题切换** - 桌面端新增亮色/暗色主题切换功能,支持用户自定义界面外观。\n\n### 其他\n\n- **桌面端 golang.org/x/sys 提升为直接依赖** - 将 `golang.org/x/sys` 从间接依赖提升为直接依赖,确保依赖关系清晰。\n\n## [v2.8.12] - 2026-05-28\n\n### 修复\n\n- **Linux 桌面构建依赖与运行环境更新** - 更新 CI 中 Linux 桌面构建的系统依赖为 gtk4 与 webkitgtk-6.0,构建运行环境切换至 ubuntu-24.04,确保桌面端在最新 Linux 发行版上正常编译与运行。\n\n## [v2.8.11] - 2026-05-28\n\n### 新增\n\n- **桌面端浏览器直连 Vite dev 端口可走通初始化** - 开发模式下支持浏览器直接连接 Vite dev 端口完成初始化流程(mock Wails IPC),无需通过 Wails 桌面窗口即可调试前端。\n\n### 修复\n\n- **WebUI 快速添加渠道不再误解析配置键** - 修正输入解析,避免 `api_key:`、`url:` 等字段名被识别为 API Key;解析逻辑改为保护 URL 中的冒号,并增加常见配置键名白名单过滤。\n- **WebUI 快速添加渠道后新渠道始终置顶** - 修正快速添加流程中渠道定位方式,不再依赖 \"index 最大\" 的启发式;改为按渠道名称精确匹配新渠道,确保新渠道保持在第一位并触发促销期。\n- **Windows 首次启动语言检测按平台分支** - 桌面端分平台检测系统语言,修复 Windows 首次启动时语言显示为英文的问题。\n- **Linux AppImage 使用 Ubuntu 22.04 构建以兼容 GLIBC 2.35** - CI 构建环境从新版 Ubuntu 降级到 22.04,确保 AppImage 在较旧 Linux 发行版上正常运行。\n\n### 文档\n\n- **推荐下载卡片增加 gh-proxy 加速下载链接** - 为下载卡片添加 gh-proxy 加速镜像链接,提升国内用户下载体验。\n- **站点 hero 区域单行标题与 tagline 扩展** - hero 区域改为单行标题、tagline 扩展上游列表与 env 代码块高亮。\n\n## [v2.8.10] - 2026-05-28\n\n### 新增\n\n- **桌面端多语言切换** - 新增轻量 i18n 框架(zh-CN / en),默认英文并自动跟随系统语言,首次启动按系统语言选择、手动切换后持久化到 `ui-preferences.json`;DesktopService 暴露 `GetLanguagePreference` / `SaveLanguagePreference`;侧栏底部守护面板新增语言切换入口;Sidebar、Setup、Status(含 LogViewer、DiagnosticCard)、Agent(含 ProviderForm、ConfigDiffDialog)、Channel、Env、WebUI 等用户可见文案全部迁移到 `t()`,便于 Microsoft Store Windows 截图审核。\n- **桌面端 Agent 配置支持 OpenCode** - configservice 新增 `PlatformOpenCode` 平台、`OpenCodeProxyState`、JSONC provider 块解析与 `auth.json` 读写;默认 ccx provider 走 `http://127.0.0.1:/v1`,可选直连 DeepSeek/MiMo/Compshare/Kimi/GLM/MiniMax/DashScope/OpenCode Zen/Go;写入 `~/.config/opencode/opencode.jsonc` 与 `~/.local/share/opencode/auth.json`;前端 AgentTab/AgentCard/ConfigDiffDialog 渲染 OpenCode 卡片;同步补充中英文 OpenCode 客户端 Desktop 自动配置文档。\n- **静默版本检查替代 wails updater 自动弹窗** - 关闭 wails updater 30 分钟自动 `CheckAndInstall`(无差别弹窗,失败场景尤为干扰);新增 `desktopservice.CheckLatestRelease(force)` 直查 GitHub Releases,过滤 prerelease,服务端缓存 4 小时(错误 30 分钟);侧栏版本号旁追加「新版 vX.X.X」胶囊,仅有更新时显示,点击打开 release 页(Store 分发不显示);抽出 `useReleaseCheck` composable(启动 8s 首次检查、之后 4 小时一次);托盘菜单「检查更新…」入口保留,主动点击仍走完整 wails updater 安装流程。\n- **OpenCode 直连仅保留官方原生支持的国产渠道** - OpenCode 卡片下拉移除 MiMo/Compshare/DashScope(OpenCode 官方未原生支持),保留 ccx + DeepSeek/Kimi/GLM/MiniMax + Zen/Go;`useAgentConfig` 同步补齐对应 baseURL 分支。\n- **渠道中心 preset 文案 i18n 化并区分 plan 渠道名** - 新增 `translateOrFallback` / `tf` helper,preset/plan/target 文案按 key 翻译、缺失 key 回退到 Go preset 原文;新增 `preset-messages.ts` 仅维护 EN,zh-CN 复用后端中文;channelName 默认值在选中非默认 plan 时追加 plan suffix,避免 MiMo/DashScope 多套餐同名覆盖。\n\n### 修复\n\n- **/v1 等裸 API 前缀不再被 SPA 兜底成 WebUI** - `isAPIPath` 仅匹配带尾部斜杠的前缀(\"/v1/\" 等),导致 `GET /v1`、`GET /api`、`GET /admin` 这类裸前缀落到 NoRoute 的 SPA 兜底分支返回 `index.html`;改为同时匹配 `path == prefix` 与 `prefix+\"/\"`,并补上之前漏掉的 `/v1beta`(Gemini 原生格式);新增表驱动测试覆盖裸前缀、子路径以及 `/v1custom` / `/apifoo` 等非 API 路径。\n- **状态页 ServiceDetails 去除与 Sidebar 重叠的 PID/Health 信息** - 移除卡片内 PID 与健康状态行(Sidebar 已展示),同时去掉冗余小标题,避免与左侧栏视觉重叠。\n- **统一 Agent 卡片 Provider 下拉文案至 i18n** - Codex 卡片 ccx 选项原使用 `agent.localGateway`(\"当前 CCX 网关\"),与 Claude/OpenCode 卡片的 `agent.provider.localGateway`(\"CCX 本地网关\")不一致;OpenCode 卡片其余选项及\"访问官方控制台\"按钮、API Key placeholder 也是硬编码中文;统一改用 `agent.provider.*` 与 `agent.openConsole` i18n key。\n- **注册 mdi-head-snowflake 图标** - AddChannelModal 的\"回传 Thinking Blocks\"开关使用 `mdi-head-snowflake`,但 `vuetify.ts` 未导入,运行时图标回退到占位文本;补 `@mdi/js` 导入与 `iconMap` 映射。\n\n### 优化\n\n- **收紧 ServiceDetails 卡片上下内边距** - 桌面端状态页 ServiceDetails 卡片上下内边距收紧,提升信息密度。\n- **语言切换合入侧栏底部守护面板** - 桌面端语言切换入口整合到侧栏底部守护面板,与现有偏好控件统一布局。\n\n### 文档\n\n- **新增桌面端教程截图** - 为桌面端使用指南补充压缩后的 CCX Desktop 截图,并将端口引用对齐到默认 3688。\n\n## [v2.8.9] - 2026-05-27\n\n### 修复\n\n- **MiMo 计费目标 URL 不更新** - 修复桌面端 MiMo 计费配置切换后目标 URL 未正确更新的问题。\n- **Claude 渠道思考块透传缺失** - 修复 Claude 渠道返回 reasoning/thinking blocks 时未按配置回传的问题。\n- **Responses 透传渠道预设误展示 gpt-5.x 一键设置** - Responses 协议透传渠道隐藏不适用的 gpt-5.x 一键预设,避免误配置。\n- **导引完成后默认页签错误** - 桌面端首次导引完成后默认跳转 Agent 配置,而非渠道中心。\n- **Sidebar 导入路径错误** - 修复桌面端 `Sidebar.vue` 绑定导入路径使用 `@/bindings` 导致的问题,改为正确的 `@bindings`。\n- **Responses 到 Claude 缺少 max_tokens 默认值** - 修复 Responses → Claude 链路缺少 `max_tokens` 默认值的问题。(#133)\n\n### 其他\n\n- **补充 max_tokens 边界测试** - 为 Responses → Claude 转换链路补充 `max_tokens` 边界测试,覆盖默认值与异常边界。\n\n## [v2.8.8] - 2026-05-27\n\n### 新增\n\n- **DashScope Coding Plan 选项** - 桌面端 DashScope provider 新增 Coding Plan 计费选项,Claude 直连 Agent 配置支持 Coding Plan 选择器。\n\n### 变更\n\n- **桌面端自更新从手写 updater 改为 Wails v3 内置 Updater** - 删除 `desktop/internal/updater/` 包(含 macOS DMG 挂载、Windows NSIS 安装器、Linux AppImage nohup 替换等平台安装器,行为模式易被杀软误杀);移除 `CheckUpdate`/`DownloadAndInstall`/`CancelUpdate` Go 导出方法和 `UpdateInfo` 结构体;改用 Wails v3 `pkg/updater`(GitHub Releases Provider + SHA256SUMS 校验 + 内置更新 UI 窗口 + 30 分钟定时检查);删除前端 `useUpdater.ts` composable 和 `UpdateDialog.vue` 组件,Sidebar 版本按钮改为纯展示(更新检查通过托盘菜单触发);Wails v3 依赖从 alpha.95 升级到 alpha.96\n- **DashScope 按量付费计划标签对齐 MiMo 规范** - 统一 DashScope 渠道预设的按量付费计划标签显示,与 MiMo 规范保持一致。\n\n### 修复\n\n- **Chat 渠道选择 Responses 上游时请求格式与端点错误** - 修复 Chat 渠道 `serviceType: \"responses\"` 时仍以 Chat 格式发送到 `/v1/chat/completions` 的问题;新增 Chat↔Responses 双向协议转换(请求:messages→input, max_tokens→max_output_tokens;响应:output→choices),流式 SSE 事件转换(response.output_text.delta→Chat chunk),前端预期请求 URL 同步修正为 `/v1/responses`。(#130)\n- **规范化 role 开关提示不够明确** - 明确 i18n 中规范化 role 开关的提示文案,注明 developer 角色转换与国内模型建议。\n\n### 文档\n\n- **添加英文 CCX Desktop 指南和入口链接** - 新增英文版 CCX 桌面端使用指南与文档入口链接。\n\n## [v2.8.7] - 2026-05-27\n\n### 文档\n\n- **完善桌面端用户教程与客户端接入入口** - 补充桌面端使用教程与客户端接入文档入口说明。\n\n### 重构\n\n- **MiMo 渠道预设移除自定义 base URL,优化 token plan 切换与下拉标签** - MiMo 渠道预设不再设置自定义 base URL,同时优化 token plan 切换逻辑与下拉标签显示。\n- **go fmt 格式化 channelpreset 缩进对齐** - 对桌面端 channelpreset 模块执行 go fmt 统一格式化。\n\n### 修复\n\n- **含图请求不再覆盖普通文本 Trace 亲和** - 含图请求 failover 到视觉渠道成功后,不再改写同一用户的普通文本 Trace 亲和,避免 DeepSeek 等文本渠道被长时间绕过;`HasImageContentCached` 只读缓存,不影响已有 Images 渠道行为。\n- **恢复 BuildPayload Description 写入,移除 DMG 打包错误静默** - 恢复桌面端 BuildPayload 中 Description 字段的写入,移除 DMG 打包流程中对错误的静默处理以便排查问题,同时格式化相关测试文件。\n\n### 其他\n\n- **补充 NoVisionModels 断言覆盖** - 为桌面端 NoVisionModels 相关逻辑补充断言覆盖,增强测试完整性。\n\n## [v2.8.6] - 2026-05-26\n\n### 新增\n\n- **Images 渠道内置模型列表新增 gpt-image-2** - 前端 Images 渠道的内置模型选项新增 `gpt-image-2`。\n- **渠道中心 provider 官方控制台链接** - 桌面端渠道中心为各 provider 添加官方控制台访问链接,方便快速跳转管理;badges 与链接布局调整至标题同行,避免错位。\n- **macOS DMG 安装包自定义拖拽背景** - 新增自定义 DMG 背景图与安装布局,Applications 快捷方式与 .app 拖拽区对齐 FlyShadow 风格。\n\n### 变更\n\n- **Compshare 渠道预设调整** - 高级模型重定向(opus/sonnet/gpt-5.4)从 deepseek-v4-pro 改为 glm-5.1;Chat/Responses 渠道移除内置模型重定向(用户自行在客户端指定模型);视觉策略从全渠道 NoVision 改为仅 deepseek-v4-flash 关闭视觉,并设置 MiniMax-M2.7 为视觉回退模型。\n\n### 修复\n\n- **添加渠道不写入描述,新渠道插入列表首位** - 修复添加渠道时描述字段未写入的问题,新增渠道现在默认插入列表首位而非末尾。\n- **渠道预设视觉与重定向策略重构** - 统一桌面端渠道预设的视觉(NoVision)与模型重定向配置,修正多个预设中不一致的策略。\n\n## [v2.8.5] - 2026-05-26\n\n### 新增\n\n- **桌面端 Store MSIX 分发支持** - Windows 发布流程新增 Store MSIX 分发配置与打包脚本,为 Microsoft Store 分发补齐 manifest、package 脚本和 release 产物链路。\n- **桌面端 Compshare 推广入口** - 桌面端新增 Compshare 推广入口与外链打开能力,便于用户从侧边栏访问相关服务。\n- **Compshare Agent 直连支持** - 桌面端 Agent 配置新增 Compshare provider 选项,并同步支持直连配置状态识别与应用。\n- **Compshare 渠道预设** - 渠道中心新增 Compshare 预设,补充对应模型映射、服务类型和预设测试覆盖。\n\n### 文档\n\n- **隐私政策说明** - 新增中英文隐私政策页面,并将其接入文档导航。\n- **首页下载推荐展示优化** - 调整 README 首页下载推荐展示,并补充 VitePress 自适应下载推荐组件。\n- **首页自适应下载推荐** - 文档首页新增根据平台自适应展示推荐下载项的能力。\n\n### 其他\n\n- **合并 Compshare 桌面渠道支持** - 合并 Compshare 桌面渠道相关功能分支,纳入本次发布范围。\n\n## [v2.8.4] - 2026-05-25\n\n### 修复\n\n- **桌面端自动更新前停止后端进程** - 自动更新下载并校验安装包后,会先停止由桌面壳托管的 Go 后端,再启动平台安装器;如果当前后端由外部进程托管或停止失败,则中止更新并提示用户手动停止后重试,避免 Windows 安装时后端二进制被占用导致只更新 UI。\n\n## [v2.8.3] - 2026-05-25\n\n### 修复\n\n- **桌面端重启 CCX 服务后误报启动失败** - 网关监控重启服务时,停止与启动阶段改用独立超时上下文,避免停止耗时挤占启动健康检查时间;同时健康检查成功后清理过期错误状态,避免日志显示已重启成功但状态卡片仍展示启动失败。\n- **静默 health 轮询访问日志** - health 检查轮询现在遵循静默轮询日志配置,避免高频健康检查污染访问日志。\n- **桌面端窗口拖动时背景闪烁** - 桌面端窗口背板改用不透明背景,降低拖动窗口时的透明闪烁感。\n- **Logo 默认动画导致界面闪烁** - 关闭 Logo 默认动画,避免页面初始渲染和切换时出现不必要的视觉闪烁。\n- **Responses 单渠道模式未追踪对话数据** - 修复 Responses 单渠道成功请求未写入对话追踪器的问题,使驾驶舱能够正确展示单渠道对话数据。\n\n### 其他\n\n- **补充 Responses 单渠道对话追踪回归测试** - 为 Responses 单渠道成功请求新增 handler 级测试,验证请求会写入 `ConversationTracker` 并生成驾驶舱对话记录,防止 `/conversations` 再次为空。\n\n## [v2.8.2] - 2026-05-25\n\n### 新增\n\n- **桌面端最小化后隐藏 macOS Dock 图标** - macOS 下窗口最小化后同步隐藏 Dock 图标,减少后台运行时的 Dock 干扰。\n- **桌面端路径项支持打开目录/编辑器操作** - Agent 相关路径展示新增纯图标操作按钮,可直接打开目录或用编辑器打开配置文件。\n\n### 修复\n\n- **桌面端渠道预设模型重定向键统一为 `gpt-5`** - MiMo、MiniMax、OpenCode Zen、OpenCode Go 的 Chat / Codex Responses 单模型重定向现在统一使用 `gpt-5` 作为源模型键,匹配 Codex / Chat 客户端实际请求模型;同时集中整理渠道预设的模型映射、reasoning 映射和 Codex 兼容开关配置。\n- **桌面端中文系统默认语言仍为英文** - 环境配置表单在 `.env` 缺少 `APP_UI_LANGUAGE` 时会读取 WebView 暴露的系统/浏览器语言,中文系统默认填入 `zh-CN`,非中文系统继续默认 `en`,且不会覆盖用户已有 `.env` 设置。\n- **桌面端 MiniMax 渠道预设与 Codex 兼容配置不完整** - MiniMax Responses 预设现在使用 `gpt-5 -> MiniMax-M2.7` 模型映射,并启用非标准 Chat 角色归一化;同步补充渠道预设测试对归一化字段的断言。\n- **桌面端 Codex 应用 CCX 渠道时 auth 密钥不一致** - Codex Agent 配置现在优先读取桌面 dataDir、项目根目录和 `backend-go` 下 `.env` 中的 `PROXY_ACCESS_KEY`,再回退到进程环境变量或自动生成值,避免 `~/.codex/auth.json` 写入与实际后端配置不一致的密钥;同时增强 `.env` 解析以兼容 `export PROXY_ACCESS_KEY=...` 和等号周围空格。\n- **能力测试复制到其他 Tab 时原生协议类型错误** - 当能力测试中某个原生协议测试成功后复制渠道到其他协议 Tab 时,新增渠道的 `serviceType` 现在按成功协议映射为对应原生上游类型(Claude Messages / OpenAI Chat / Gemini / Responses),不再沿用源渠道的协议类型;转换按钮同时保留目标 Tab 与成功协议两个语义,避免复制到目标 Tab 时写入错误上游类型。\n- **桌面端初始配置完成状态误读环境变量** - `IsSetupComplete` 现在只读取桌面 dataDir 下的 `.env`,避免进程环境变量或项目根目录 `.env` 让首次启动引导被错误跳过,并补充对应单元测试。\n- **桌面端 Agent 状态路径展示溢出** - Agent 卡片中的当前/目标 URL、配置文件和认证文件路径改为固定标签列 + 可换行内容列,保留编辑器打开按钮且避免长路径挤压布局。\n- **桌面端网关监控详情字段未对齐** - 网关监控详情展示字段与后端服务状态字段保持一致,避免状态信息展示不完整或错位。\n- **桌面端托盘隐藏 Dock 图标时崩溃** - 修复托盘状态下触发隐藏 Dock 图标可能导致桌面端崩溃的问题。\n- **桌面端渠道预设刷新按钮点击报错** - 移除无效的刷新预设按钮,避免点击后触发前端异常。\n- **桌面端页面内容区宽度受限** - 移除页面内容区 `max-w-5xl` 限制,使桌面端页面能够自适应占满可用空间。\n\n### 文档\n\n- **补充渠道预设模型重定向说明** - 在变更记录中补充渠道预设模型重定向的说明,明确 Codex / Chat 客户端实际请求模型与预设映射关系。\n- **调整赞助商展示样式** - 优化 README 中赞助商展示区域的排版与视觉呈现。\n- **添加赞助商注册链接** - 在 README 赞助商信息中补充可直接访问的注册链接。\n- **添加优云智算赞助商信息** - README 新增优云智算赞助商介绍与展示图片。\n\n### 其他\n\n- **version-bump 增强 CHANGELOG 完整性校验** - 版本升级技能现在会校验从上一版本 tag 到 HEAD 的提交记录,补全或移动遗漏的 CHANGELOG 条目。\n- **桌面端 Wails 绑定产物切换为 TypeScript** - 当前工作区包含 `desktop/frontend/bindings/` 下生成绑定从 `.js` 文件删除到 `.ts` 文件新增的变更,需随本轮未提交改动一起审核。\n\n## [v2.8.1] - 2026-05-25\n\n### 修复\n\n- **桌面 App 内嵌 Web UI 删除渠道不生效** (#115) - Wails 3 的 WKWebView 在 iframe 上下文中对原生 `window.confirm()` 不弹对话框、静默返回 `false`,导致删除请求从未发出;替换为 Vuetify `v-dialog` 实现的通用确认框,兼容桌面 iframe 与浏览器双入口\n- **LogViewer autoScroll 开关失效,新日志仍强制滚动** - 移除对 `filteredLogs` 的冗余 watch,改为监听 `searchQuery` 仅在用户改变搜索关键字时强制滚动;新日志增加时改走受 `autoScroll` 控制的 `props.logs.length` watch\n- **缩短引导界面生成的 PROXY_ACCESS_KEY 长度** - 随机熵从 24 字节降为 8 字节,密钥从 `ccx-` + 48 字符 hex(52 字符)改为 `ccx-` + 16 字符 hex(20 字符);64 位熵对本地代理网关已足够安全,显著改善初始引导界面的可读性与可复制性\n\n### 文档\n\n- **修正渠道名称说明,反映同名覆盖的实际行为** - 后端 `createChannel` 在检测到同名渠道时会执行 PUT 覆盖更新,原文案 \"如重复,CCX 会拒绝创建\" 与实际行为不符,容易造成误导\n\n## [v2.8.0] - 2026-05-25\n\n### 修复\n\n- **TestDetectRootDirFallsBackToCwd 在 macOS `/private/var` 符号链接下的误报** - macOS 的 `/var` 是 `/private/var` 的符号链接,`t.TempDir()` 返回前者但 `os.Getwd()` 解析到后者,导致字符串直接比较失败;改用 `filepath.EvalSymlinks` 对齐期望值\n\n### 其他\n\n- **同步 channelpreset 测试期望与当前 preset 实现** - 修复 9 个因测试期望过时导致的失败用例,实现本身正确\n - 移除对 `SupportedModels` 字段的断言:`preset.go` 从未设置该字段(deepseek_chat 与 mimo 共 4 个用例)\n - `glm_chat` / `glm_responses` BaseURL 改为 coding plan 的实际值(`https://open.bigmodel.cn/api/coding/paas/v4#`),匹配 `bestPlanForTarget` 按 plan 数组顺序选第一个非 anthropic plan 的行为\n - `minimax_responses` 的 `CodexToolCompat` / `StripCodexClientTools` 改为 false:`applyTargetDefaults` 显式 override 为 native passthrough 策略,与 DeepSeek 一致\n - `TestBuildPayloadRejectsUnsupportedTarget` 改用 `invalid-target`:kimi preset 已支持 `NativeMessages`,原 kimi+messages 用例失效\n\n## [v2.7.31] - 2026-05-25\n\n### 破坏性变更\n\n- **桌面端 dataDir 路径稳定化,移除 sha1 hash 子目录** - 历史版本将 dataDir 计算为 `{UserConfigDir}/ccx-desktop/{sha1(rootDir)[:10]}`,rootDir 由 `os.Getwd()` 决定,导致从 Dock、Spotlight、终端 `wails3 dev` 等不同方式启动时产生不同 hash 目录(实测一台机器下出现 3 个互不相通的目录),用户的 PROXY_ACCESS_KEY、渠道配置、Agent 配置快照在不同启动方式之间相互不可见\n - 新版本统一使用 `{UserConfigDir}/ccx-desktop/`,与 `bootstrap.log` 同级,dev 与 prod 共用\n - **升级影响**:从旧版本升级后,原 hash 子目录(如 `ccx-desktop/42099b4af0/`)不会自动迁移,用户首次启动会进入空配置状态,需要重新通过引导页设置或手动从旧 hash 目录复制 `.env`、`agent-config-state/`、`.config/` 到新位置\n - 不做自动迁移的原因:无法可靠判断哪个 hash 目录代表\"主用户数据\"\n\n## [v2.7.30] - 2026-05-25\n\n### 新增\n\n- **桌面端首次启动引导流程** - 强制要求用户在启动 CCX 服务前完成最小可用配置,避免直接进入主界面后调用失败\n - 新增 SetupView 全屏引导页:检测 `.env` 中无 `PROXY_ACCESS_KEY` 时显示,预填随机生成的密钥(`ccx-<48hex>`),支持显示/隐藏、复制、重新生成\n - 完成后自动合并写入 `.env`(保留已有键值与注释),自动启动 CCX 服务并跳转至【渠道中心】标签页\n - 引导页展示 `.env` 文件路径方便后续调试,提示后续可在【环境参数】页继续修改其他字段\n - 新增 `IsSetupComplete` / `GenerateProxyAccessKey` 两个 Wails 绑定方法,前者只读判断密钥存在性,后者仅生成预览不写入文件\n - App 启动时三态渲染(Loading 占位 / Setup 引导页 / 主界面),避免界面切换闪烁\n\n- **桌面端 bootstrap 启动日志** - 在 backend manager 初始化前写入固定位置的 `bootstrap.log`,覆盖 dataDir 计算阶段的启动诊断盲区\n - 新增 `mustGetwd` / `mustExecutable` 辅助函数,记录关键启动阶段\n - 便于 Windows 双击无反应、dataDir 解析异常等场景的故障排查\n\n### 修复\n\n- **桌面端版本号显示重复 `vv` 前缀** - 修复桌面端左下角及更新弹窗版本号显示为 `vv2.7.29` 的问题\n - VERSION 文件已自带 `v` 前缀,前端模板不应再硬编码 `v`\n - 修正 `Sidebar.vue` 左下角版本徽标,以及 `UpdateDialog.vue` 标题处的当前/最新版本对比\n\n- **detectRootDir 向上遍历边界条件死循环** - 修复 `filepath.Dir` 到达根目录后返回自身导致的潜在死循环\n - 改为显式检测 `parent == dir` 终止\n - 新增测试验证 fallback 到 cwd 的行为\n\n### 其他\n\n- **NSIS 构建添加 `/INPUTCHARSET UTF8`** - 修复 NSIS 在非 ASCII 路径下编译失败的问题,并将 `ccx-go.exe` 构建产物加入 `.gitignore`\n- **新增 `make install` 命令** - 一键安装根目录、`backend-go/`、`frontend/`、`desktop/`、`desktop/frontend/` 全部依赖\n\n## [v2.7.29] - 2026-05-25\n\n### 修复\n\n- **批量延迟测试在 Chat 标签页报错 `e.forEach is not a function`** - 修复 Chat 渠道批量延迟测试时,前端 `pingAllChatChannels()` 未解包后端 `{\"channels\": [...]}` 响应导致 `forEach` 调用失败的问题\n - `api.ts` 中 `pingAllChatChannels()` 现在正确提取 `resp.channels` 并映射字段,与 Images/Gemini 处理方式一致\n\n- **第三方 provider 直连使用 ANTHROPIC_AUTH_TOKEN** - 修复桌面端第三方 provider(DeepSeek、MiMo、Kimi、GLM、MiniMax、DashScope、OpenCode)直连 Claude Code 时,API Key 错误写入 `ANTHROPIC_API_KEY` 的问题,改为正确写入 `ANTHROPIC_AUTH_TOKEN`\n - `resolveClaudeProvider` 统一将第三方 provider 的 key 归入 `authToken` 返回值,与 CCX provider 行为一致\n - apply、restore、preview 三条路径均通过同一函数解析,无需额外修改\n\n- **配置预览 Diff 掩码后丢失敏感字段变更** - 修复 Agent 配置预览弹窗中,敏感字段(如 `ANTHROPIC_AUTH_TOKEN`)变更时因掩码为 `***` 导致 diff 无法识别变更的问题\n - 新增 `computeJSONDiffWithMask` / `computeTextDiffWithMask`:先在原始内容上做 LCS diff 正确识别变更类型,再用掩码后的内容填充展示行\n - 更新全部 6 个 preview 函数(Claude/Codex apply & restore)使用先 diff 再掩码的方式\n - 新增 `extractNestedStringValues` 支持从嵌套 map(如 `env` 子 map)提取敏感字段值\n - 新增 4 个单元测试覆盖掩码 diff 核心场景\n\n## [v2.7.28] - 2026-05-24\n\n### 修复\n\n- **Codex 第三方 provider 状态识别与恢复逻辑修复** - 修复桌面端 Codex 直连模式下第三方 provider 的运行状态识别和恢复逻辑\n- **Codex Responses 转 Chat 时空 tool_choice 导致上游 400** - 修复当 Codex 请求仅携带非 function 类型 tools(全被过滤)时,`tool_choice` 和 `parallel_tool_calls` 仍被透传给上游 Chat API 导致拒绝的问题\n - `OpenAIChatConverter` 和 `ConvertResponsesToOpenAIChatRequest` 两条路径均增加 tools 存在性检查\n - 无 tools 时自动剥离 `tool_choice` 和 `parallel_tool_calls`,与透传路径 `stripCodexClientOnlyTools` 行为一致\n\n- **WebUI iframe 切换标签时自动刷新** - 修复桌面端切换标签页后 WebUI iframe 白屏的问题,切回 WebUI 标签时自动刷新内容\n\n- **Windows 桌面端双击无反应** - 修复 Windows 生产构建(`-H windowsgui`)下启动失败时错误静默吞掉的问题\n - 添加 Windows MessageBox 弹窗:WebView2 缺失、ccx-go 找不到等致命错误现在会弹窗提示用户\n - 添加顶层 `recover()` 兜底:未处理的 panic 不再导致进程静默退出\n - 添加单实例互斥锁:重复双击时弹窗提示\"已在运行中\",检查系统托盘\n - 添加持久化文件日志:`%APPDATA%\\ccx-desktop\\\\desktop.log` 记录启动日志便于排查\n - NSIS 安装器 `ccx-go.exe` 从可选改为必需,缺失则安装失败\n\n## [v2.7.27] - 2026-05-24\n\n### 新增\n\n- **Codex 直连新增 DashScope / OpenCode Zen / Go 选项** - 桌面端 Codex 直连配置新增 DashScope、OpenCode Zen 和 OpenCode Go 三个新的 provider 选项\n- **DashScope chat/responses 预设扩展** - DashScope 渠道预设增加 GPT 模型重定向和 reasoning mapping 支持\n\n### 修复\n\n- **Codex 直连目标 URL 显示** - 修复 Codex 直连时目标 URL 未正确显示各 provider 的 Responses 端点的问题\n\n## [v2.7.26] - 2026-05-24\n\n### 新增\n\n- **多渠道 Messages 原生透传支持** - Kimi、GLM、MiniMax 新增 Messages 原生透传,DeepSeek codex responses 预设默认开启原生工具透传和 role 规范化\n- **渠道预设扩展** - 新增 OpenCode Zen/Go 和阿里云 DashScope 渠道预设与 Agent 直连支持\n- **Agent 直连选项** - Agent 配置新增 Kimi / GLM / MiniMax 直连选项\n- **渠道中心自动覆盖** - 添加到 CCX 时自动覆盖同名渠道配置\n- **Web UI 刷新按钮** - 内嵌 Web UI 页面添加刷新按钮\n- **Codex OpenAI 切换** - 支持 Codex 无缝切换回 OpenAI provider\n\n### 修复\n\n- **渠道中心文案与 URL** - 修正描述文案错别字,移除 /v1 结尾 base URL 的 # 后缀\n- **渠道中心 target 切换** - 修复切换 target 后被级联重置回 messages 的问题\n- **桌面端二进制路径** - 修复 ccx-go 后端二进制未打包到 App Bundle 的路径错误\n- **vite 插件路径** - 修复 vite 插件路径为绝对路径并更新前端依赖\n- **OpenAI provider 配置** - 修复 OpenAI 模式下目标 URL 显示为空,移除无效配置块写入\n- **监控面板初始化** - 初始化时预探测二进制路径,避免监控面板显示\"未发现\"\n\n### 重构\n\n- **Web UI 刷新按钮位置** - 刷新按钮移至标题栏,WebUITab 通过 defineExpose 暴露刷新方法\n- **渠道预设精简** - 移除渠道预设中所有 SupportedModels 显式赋值,MiniMax 预设精简默认值\n- **ccx-go 查找路径** - 改为向上遍历最多 6 层目录\n\n### 其他\n\n- **Wails 升级** - Wails v3.0.0-alpha.92 升级到 alpha.95\n\n## [v2.7.25] - 2026-05-23\n\n### 修复\n\n- **渠道预设模型映射更新** - 更新桌面端渠道预设的模型映射配置\n- **WebUI 默认语言改为中文** - 前端 WebUI 默认语言从英文改为中文\n- **Agent 代理配置应用按钮文案统一** - 统一桌面端 Agent 代理配置的应用按钮文案\n- **Checksum 文件路径前缀修正** - 去除 CI Release 流程中 checksum 文件的路径前缀\n\n### 其他\n\n- **桌面应用图标资源更新** - 更新桌面应用图标资源文件\n\n## [v2.7.24] - 2026-05-23\n\n### 修复\n\n- **Release workflow Bad credentials 错误** - 修复 CI Release workflow 中 GitHub token 认证失败的问题\n\n## [v2.7.23] - 2026-05-23\n\n### 修复\n\n- **MiMo 不同计费模式支持独立 API Key** - ProviderKeyAsset 的 Assets map 改为 `provider:planID` 复合 key,同一 provider 不同 plan 不再互相覆盖;前端查找保存的 key 时优先匹配 plan-specific key,回退到 provider-only key 兼容旧数据\n\n## [v2.7.22] - 2026-05-23\n\n### 修复\n\n- **桌面端 CCX Provider 应用按钮状态修复** - CCX provider 的应用按钮不再依赖网关在线状态,避免网关未启动时无法操作\n- **Release checksum 路径修复** - 修复 Release checksum 生成路径错误导致三个主平台构建失败的问题\n\n## [v2.7.21] - 2026-05-23\n\n### 修复\n\n- **Release checksum 路径前缀修正** - 修复 CI release 流程中 checksum 文件路径前缀错误并精简 finalize job\n- **桌面构建 ccx-go 查找路径** - 修正桌面端构建时 ccx-go 后端二进制的查找路径\n- **渠道中心 Base URL 过滤** - 桌面端渠道中心按 target 协议过滤 Base URL 选项,避免显示不匹配的选项\n\n### 其他\n\n- **恢复 Wails 3 TypeScript 绑定文件** - 恢复桌面端 Wails 3 自动生成的 TypeScript 绑定文件\n- **version-bump 技能文档更新** - 更新 version-bump 技能的 GitHub Actions 集成说明,新增 `gh run list` 查看构建进度命令\n\n## [v2.7.20] - 2026-05-23\n\n### 修复\n\n- **MiMo 渠道模板 Base URL 修正** - 修正 MiMo 渠道模板 Base URL 为 Anthropic 协议入口,同时调整计费模式表单顺序\n- **网关监控默认显示配置端口** - 网关监控打开时默认显示 .env 中配置的端口,启动后端时优先使用用户配置的端口\n- **Windows Desktop amd64 安装包缺失** - 修复 CI 中 Windows Desktop amd64 安装包在 Release 中缺失的问题\n- **macOS 应用图标重新设计** - 重新设计应用图标以适配 macOS Human Interface Guidelines\n- **macOS 托盘图标交互修复** - 修复 macOS 托盘图标点击后窗口置顶和位置偏移的问题\n\n## [v2.7.19] - 2026-05-23\n\n### 修复\n\n- **日志文件混入精简格式重复内容** - 修复 logger 将 stdout 精简格式与原始完整 JSON 双重写入日志文件的问题,确保日志文件仅保留原始完整记录\n\n## [v2.7.18] - 2026-05-23\n\n### 修复\n\n- **DMG 安装体验与图标显示** - 改善 macOS DMG 安装包的安装体验与应用图标显示效果\n\n## [v2.7.17] - 2026-05-23\n\n### 修复\n\n- **Task 依赖链 ARCH 传递遗漏** - 修复 CI 中 Task 依赖链未正确传递 ARCH 环境变量的问题\n\n## [v2.7.16] - 2026-05-23\n\n### 修复\n\n- **桌面构建 ARCH 变量未通过 Task 依赖链传递** - 修复 CI 中桌面端构建时 ARCH 环境变量未正确传递到下游 Task 步骤的问题\n\n### 其他\n\n- **版本升级技能新增本地编译验证** - version-bump 技能在创建 tag 前强制执行本地测试和构建验证,编译失败时中止发布流程\n\n## [v2.7.15] - 2026-05-23\n\n### 变更\n\n- **移除后端 OTA 更新机制** - 删除后端 updater 包及相关 API 接口,桌面端更新改为跳转到 GitHub Releases 下载页面\n\n## [v2.7.14] - 2026-05-23\n\n### 新增\n\n- **桌面端 .env 密码字段复制按钮** - .env 配置中密码类型字段(如 `PROXY_ACCESS_KEY`)添加复制按钮,方便用户快速复制密钥值\n\n### 修复\n\n- **密码字段留空时隐藏复制按钮** - 当密码类型字段值为空时自动隐藏复制按钮,避免无效操作\n\n### 变更\n\n- **移除后端 OTA 自动更新** - 删除 `updater` 包、`/api/update/check` 接口及相关配置,桌面端更新改为跳转到 GitHub Releases 下载页面\n\n### 其他\n\n- **Release workflow 桌面构建前置修复** - CI 桌面端构建步骤前确保 `bin` 目录存在,修复构建流程中目录缺失问题\n\n## [v2.7.13] - 2026-05-23\n\n### 新增\n- **Linux ARM64 桌面端支持**:CI 新增 `ubuntu-24.04-arm` runner 构建 arm64 AppImage,updater 移除 arm64 限制。\n- **前端测试框架**:引入 vitest + jsdom,新增 `env-file.ts` 20 个单元测试。\n- **Bindings 同步 CI 守护**:新增 `check-bindings.yml` workflow,PR 修改 Go 接口时自动验证 TypeScript bindings 同步。\n- **桌面端 .env 文件外部编辑器打开**:Env 配置页新增\"用编辑器打开\"按钮,自动检测系统已安装的编辑器(VS Code、Cursor、Sublime Text、Vim 等),支持 `$EDITOR` / `$VISUAL` 环境变量优先。多个编辑器时提供下拉选择。\n\n- **Desktop 内置 ccx-go 后端**:macOS .app、Windows NSIS、Linux AppImage/deb/rpm 安装包现在内置 ccx-go 后端二进制,用户无需单独下载。Desktop 与 ccx-go 共用版本号。\n- **Release Sigstore 签名**:\n - 在 CI 发布流程中集成 Sigstore / cosign keyless signing,使用 GitHub Actions OIDC 颁发短期证书,无需管理密钥。\n - 三个构建平台(macOS / Windows / Linux)各自生成平台级 checksums 并签名,`finalize` job 合并为全平台 `checksums.txt` 后再次签名。\n - 新增 `docs/guide/verification.md` 与 `docs/en/guide/verification.md` 验证文档,说明 cosign 安装与签名验证步骤。\n - 现有 `.sha256` sidecar 文件与 updater 行为不变。\n\n### 变更\n\n- **桌面端 Env 配置访问控制置顶**:`PROXY_ACCESS_KEY` 和 `ADMIN_ACCESS_KEY` 配置组在 Env 配置表单中移到首位,便于首次配置时优先设置。\n- **补齐桌面端测试覆盖**:新增 `configservice`(19 个纯函数 + 集成测试)和 `backend/manager`(16 个测试)测试文件。\n\n### 修复\n- **MSIX 打包缺少 wails.json**:新建 `desktop/wails.json`,修复 `wails3 tool msix --config` 引用不存在的文件。\n- **parseEnvContent 兼容性**:Go 端 `.env` 解析支持 `export` 前缀和 `\\r\\n` 换行符。\n- **桌面端 Agent 配置服务初始化错误日志**:`configservice.New()` 失败时不再静默丢弃错误,改为输出 `[Desktop-Init]` 日志便于排查。\n- **渠道对话框下拉菜单位置偏移**:修复 Vuetify v-select/v-combobox 在添加/编辑渠道对话框内首次打开时菜单位置计算错误的问题,通过 eager 预渲染与 resize 触发确保下拉菜单正确定位。\n- **DeepSeek `user_id` 限速与隔离字段透传**:\n - 让内部会话标识提取支持 Chat 请求体顶层 `user_id`,用于 Trace 亲和性与调度追踪。\n - 补齐 Chat → Claude、Messages → OpenAI/Responses、Responses → Claude 跨协议转换中的 `user_id` / `metadata.user_id` 映射,避免用户隔离标识在协议转换时丢失。\n - 增加相关单元测试覆盖关键转换路径。\n- **Responses 转 Claude 上游 reasoning 回传补齐**:\n - 在 Responses 转 Claude/Messages 风格请求且启用 `PassbackReasoningContent` 时,同步补齐 `reasoning_content`,避免 MiMo 等上游跨渠道历史请求缺少 reasoning 回传字段。\n\n## [v2.7.12] - 2026-05-22\n\n### 修复\n\n- **Windows Release 构建 NSIS makensis 不在 PATH** - 修复 CI 中 Windows Release 构建时 NSIS `makensis` 工具不在 PATH 导致构建失败的问题\n\n## [v2.7.11] - 2026-05-22\n\n### 新增\n\n- **渠道预设 Token Plan 下拉显示实际 Base URL** - 桌面端渠道预设 Token Plan 下拉选项现在会显示实际的 Base URL,便于用户识别和选择\n\n## [v2.7.10] - 2026-05-21\n\n### 修复\n\n- **MiMo Messages 渠道流式响应只输出 thinking 的问题**:\n - 停止为缺少真实思考内容的历史 assistant 消息注入 `\"(no prior reasoning recorded)\"` 占位 `thinking` 或 `reasoning_content`,避免 MiMo 将正式回答续写进假 thinking block,或继续因假回传返回 `reasoning_content ... must be passed back` 400。\n - 为历史 assistant 消息补齐 MiMo 要求的顶层 `reasoning_content` 字段:保留真实 `thinking` 块原文并同步回传到顶层字段,仅移除历史请求中旧版本注入的占位/空 `thinking` 块;若 assistant 历史没有真实思考内容,则补空字符串 `reasoning_content: \"\"`(保持顶层字段存在);若历史 assistant 仅剩占位 thinking,则转为中性非空 text,避免空 content 或轮次丢失触发上游校验问题。\n - `thinking -> reasoning_content` 转换改为“保真搬运”:不再对真实思考文本做 trim/拼接改写;当消息已含顶层 `reasoning_content` 时保持原值,避免内容被改写后触发上游回传一致性校验失败。\n - 修复请求预处理误删真实 thinking 的问题:`thinking` 块里 `signature` 为空/null 时仅移除 `signature` 字段,不再删除整块 `thinking`,避免后续 `reasoning_content` 回传被掏空。\n - 增强 failover 非重试判定:当上游 400 的 `error.param`/`error.message` 命中 `reasoning_content in the thinking mode must be passed back` 时,判定为 schema 参数错误,不再继续 Key/渠道级 failover,避免请求漂移到其他渠道与错误熔断。\n - 流式预检测改为只有正式 text 或工具语义内容才视为有效响应;thinking-only 流会被判定为空响应并触发重试/失败,避免静默返回 200。\n\n## [v2.7.9] - 2026-05-20\n\n### 修复\n\n- **Gemini 渠道流式响应下 tool call ID 生成策略优化**:\n - 在 `backend-go/internal/providers/gemini.go` 中,将 Gemini 流式响应下 functionCall 的 tool ID 生成策略从不稳定的索引拼接形式(如 `toolu_%d`)优化为稳定的、带有 `call_` 前缀的 16 字节随机 hex 字符串(通过 `crypto/rand` 生成),使 ID 在跨多轮对话时更具唯一性且对齐主流(如 OpenAI `call_xxx` 风格),避免在复杂 agent 多轮调用中发生冲突。\n - 在 `backend-go/internal/providers/gemini_stream_test.go` 中添加对应的测试断言,验证 tool call ID 的 `call_` 前缀生成逻辑。\n- **修复 Gemini 流式响应中 functionCall 场景的 `stop_reason` 映射**:\n - 在 `backend-go/internal/providers/gemini.go` 中,引入 `hasToolUse` 状态标志。当流式响应中检测到 functionCall 时,将 `message_delta` 的 `stop_reason` 正确映射为 `tool_use`(而不是默认的 `end_turn`),确保下游消费端能够正确感知并启动工具调用执行,完全符合 Claude 规范。\n - 在 `backend-go/internal/providers/gemini_stream_test.go` 中新增 `TestGeminiHandleStreamResponse_FunctionCallMapsStopReasonToToolUse` 单元测试,模拟 functionCall 流式响应并验证 `stop_reason` 映射。\n\n## [v2.7.8] - 2026-05-20\n\n### 重构\n\n- **清理 `providers` 包历史 lint 告警**:\n - `backend-go/internal/providers/openai.go` 中 `ConvertToClaudeResponse` 转换 tool_call 入参时,给 `json.Unmarshal` 补上错误检查;解析失败时降级保留 `toolCall.Function.Arguments` 原始字符串,避免静默丢失参数(errcheck)。\n - `backend-go/internal/providers/openai_stream_usage_test.go` 中 `TestOpenAIProvider_ConvertToClaudeResponse_CacheFieldInJSON` 给 `json.Unmarshal` 补上 `t.Fatalf` 错误检查,防止 unmarshal 失败时后续断言基于空 map 假阳性通过(errcheck)。\n - `backend-go/internal/providers/responses.go` 删除未被任何地方调用的 `formatFunctionCallHistory` 函数(13 行):与配套 `formatFunctionCallOutputHistory` 不对称——`function_call` 分支始终直接保留原始 item,不需要降级为文本消息,该函数是早期设计残留(unused)。\n - `backend-go/internal/providers/claude.go` 中 `ConvertToProviderRequest` 模型重定向条件 `upstream.ModelMapping != nil && len(upstream.ModelMapping) > 0` 简化为 `len(upstream.ModelMapping) > 0`(Go 对 nil map 的 `len()` 定义为 0,gosimple/S1009)。\n\n## [v2.7.7] - 2026-05-20\n\n### 修复\n\n- **`messages` 走 Gemini 上游时 tool_result 函数名错位导致工具调用沉默丢失**:\n - 修复 `backend-go/internal/providers/gemini.go` 的 `convertMessage` 在转换 Claude `tool_result` 为 Gemini `functionResponse` 时,把 `name` 字段直接填成 `tool_use_id` 的问题。Gemini 协议要求 `functionResponse.name` 必须等于前面对应 `functionCall.name`(函数名),否则上游无法匹配到对应的工具调用,会沉默返回空内容(典型表现:MCP / 多轮工具历史在 Gemini 上游被静默丢弃,下游模型回合显示空白)。\n - 新增 `buildToolUseIDNameMap`,在 `convertMessages` 入口先扫一遍 Claude 历史,构建 `tool_use_id → name` 映射;`convertMessage` 接收该映射并在 tool_result 转换时回查正确函数名。查不到映射的孤立 tool_result 回退使用 `tool_use_id` 兜底,避免完全丢字段。\n - 在 `backend-go/internal/providers/gemini_tool_result_test.go` 中将历史 fixture 升级为含 tool_use 的完整对话,并新增针对 id→name 映射回查的断言用例。\n\n### 新增\n\n- **Chat 渠道透传思考回传支持**:\n - 在 `backend-go/internal/handlers/chat/handler.go` 中引入了与 Messages 渠道一致的预处理逻辑,自动清理空 signature 字段和历史畸形 thinking 内容块,预防上游参数校验 400 错误。\n - 在 `buildProviderRequest` 中,当 `PassbackReasoningContent` 关闭时,在发送给 Claude 协议上游前自动剥离历史 thinking 块,避免跨上游复用签名导致 signature 错误。\n - 在 `backend-go/internal/config/config_chat.go`、`channels.go` 和 `channel_metrics_handler.go` 中同步支持了 `PassbackReasoningContent` 字段的更新与返回。\n - 修改了 `frontend/src/components/AddChannelModal.vue`,使得“回传 Reasoning Content”开关在 Chat 渠道且服务类型为 claude 时也能正常显示和配置。\n - 在 `deepseek_thinking_matrix_test.go` 中新增了 `TestChatHandler_PassbackReasoningContent` 测试用例。\n\n### 修复\n\n- **`messages` 走 Gemini 上游时 MCP / 自定义工具历史丢失导致重复调用**:\n - 修复 `backend-go/internal/converters/responses_to_gemini.go` 中 `responsesItemToGeminiContents` 漏掉 `custom_tool_call` 与 `custom_tool_call_output` 两类 item 的问题。归一化阶段会把 MCP 工具(如 `mcp__serena__*`)落到这两种类型,原实现走 switch 的隐式 default 直接丢弃,导致历史里只剩模型空回合,Gemini 视为\"工具调用未返回\"并不停重复发起同一工具调用。\n - 现在两类 item 分别转为 `model` 角色的 `FunctionCall`(携带 `DummyThoughtSignature`,与 `function_call` 行为一致)和 `user` 角色的 `FunctionResponse`,并对缺 `call_id`/`name` 的孤立项做兜底丢弃,避免构造出 Name 为空的 part 触发上游 400。\n - 在 `backend-go/internal/converters/gemini_responses_roundtrip_test.go` 新增 `TestResponsesToGeminiRequest_PreservesCustomToolCallHistory` 和 `TestResponsesToGeminiRequest_CustomToolCallOutputWithoutCallIDDropped` 两条回归用例。\n- **SUBSCRIPTION_NOT_FOUND 余额不足故障转移**:\n - 将 `SUBSCRIPTION_NOT_FOUND` 错误码和 `\"no active subscription found\"` 错误信息识别为余额不足(`insufficient_balance`),从而正确触发渠道黑名单和故障转移。\n - 在 `failover_test.go` 和 `stream_test.go` 中补充了对应的普通请求与流式请求测试用例。\n- **Gemini 渠道 tool_result 数组解析报错**:\n - 修复了 `messages` 接口转上游 `gemini` 类型渠道时,`tool_result` 包含数组(Content Blocks)导致的反序列化报错问题。\n - 将 `GeminiFunctionResponse.Response` 字段类型从 `map[string]interface{}` 变更为 `interface{}`,提高结构体容错性。\n - 在 `backend-go/internal/providers/gemini.go` 的 `convertMessage` 方法中,对 `tool_result` 的 `content` 进行了智能解析和规范化,确保转换后的 `response` 始终是一个符合 Gemini 官方协议要求的 JSON 对象。\n - 新增了 `gemini_tool_result_test.go` 单元测试,覆盖了 `tool_result` 为数组、字符串、JSON 对象等各种场景,验证其能正确转换为 Gemini 期望的格式。\n- **Gemini 渠道空 text part 触发上游 400**:\n - 修复了 `messages` 接口转上游 `gemini` 类型渠道时,Claude assistant 消息中常出现的空 text 块(如带 tool_use 时的前置 padding)被无脑翻译为 `{\"text\": \"\"}`,被严格按 Gemini protobuf 校验的上游(如 vip.undyingapi.com)判定为 `contents[X].parts[Y].data: required oneof field 'data' must have one initialized field` 返回 400 的问题。\n - 在 `backend-go/internal/providers/gemini.go` 的 `convertMessage` 中处理 `text` 类型 content 时跳过空字符串,确保不向 Gemini 上游下发无意义的空 Part。\n - 在 `gemini_tool_result_test.go` 中新增 `TestGeminiProvider_ConvertMessage_SkipsEmptyTextBlock` 与 `TestGeminiProvider_ConvertMessage_KeepsNonEmptyTextBlock` 两条用例,覆盖空 text 块被剔除与非空 text 块仍保留两种场景。\n- **Claude→Gemini provider 转换遵循 thought_signature 渠道开关**:\n - 修复了 `messages` 接口转上游 `gemini` 类型渠道时,Claude 协议本身不携带 thought_signature 字段,导致严格校验的上游(如 vip.undyingapi.com)返回 `Function call is missing a thought_signature in functionCall parts` 400 的问题。\n - 在 `backend-go/internal/providers/gemini.go` 的 `convertToGeminiRequest` 中按上游 `injectDummyThoughtSignature` / `stripThoughtSignature` 开关注入 part 层级的 `thoughtSignature`,与原生 Gemini 入口(`handlers/gemini/handler.go`)的策略对齐:默认不修改、`injectDummyThoughtSignature` 开启时注入 `DummyThoughtSignature`、`stripThoughtSignature` 优先级更高且在该场景为 no-op(Claude 协议本就无签名)。\n - 在 `gemini_tool_result_test.go` 中新增 `TestGeminiProvider_ConvertToGeminiRequest_InjectDummyThoughtSignature`、`TestGeminiProvider_ConvertToGeminiRequest_DefaultNoSignature`、`TestGeminiProvider_ConvertToGeminiRequest_StripThoughtSignatureNoOp` 三条用例。\n- **补齐多渠道 thought_signature 字段链路**:\n - `backend-go/internal/config/config_messages.go` 的 `UpdateUpstream` 补充接收 `StripThoughtSignature`(之前漏了,开启了等于无效)。\n - `backend-go/internal/config/config_chat.go`、`config_responses.go` 的 update 函数补充接收 `InjectDummyThoughtSignature` 和 `StripThoughtSignature`,对齐 [[feedback_channel_config_field]] 的\"五类渠道更新函数必须同步应用新字段\"规则。\n - `backend-go/internal/handlers/messages/channels.go`、`chat/channels.go`、`responses/channels.go` 的 `GetUpstreams` 把这两个字段透出给前端;`handlers/channel_metrics_handler.go` 的 dashboard 端点同步补充。\n - `frontend/src/components/AddChannelModal.vue` 把 `injectDummyThoughtSignature` 开关的显示条件由仅 `props.channelType === 'gemini'` 改为 `(gemini || messages) && form.serviceType === 'gemini'`;`stripThoughtSignature` 开关的显示条件由仅 gemini 改为 `form.serviceType === 'gemini'` 且 channelType 属于 gemini/messages/chat/responses。chat/responses 渠道当前在 handler/converter 层默认无条件注入 dummy 签名,因此前端不暴露 `injectDummy` 开关以避免误导。\n- **`maskKey` 短密钥脱敏过度**:\n - 修复了 `backend-go/main.go` 中启动日志 `maskKey` 对长度 ≤ 4 的密钥直接输出 `****`、完全遮蔽用户能识别的字符的问题。\n - 现在对长度 ≤ 3 的密钥保留首字符(如 `abc` → `a****`),长度 4~8 保留首尾字符,长度 > 8 保留前后各 2 字符。\n\n### 新增\n\n- **CCX 桌面外壳 MVP** - 新增 Wails3 桌面外壳,用于将现有 CCX 后端作为核心服务构件进行启动、停止、重启、托盘驻留和状态监控;外壳通过现有 `/health` 探活并在内置标签页中加载 CCX Web UI,避免改动核心代理、调度和现有 Web 管理界面逻辑。\n - 新增 `desktop/` Wails3 项目,包含后端子进程 supervisor、托盘菜单、状态页、内嵌 Web UI 标签页和前端绑定。\n - 根 `Makefile` 新增 `desktop-dev` / `desktop-build`,复用现有前端 embed 与 Go 后端构建流程。\n - 调整 `.gitignore`,保留 Wails `desktop/build/` 源配置,同时继续忽略桌面构建产物。\n\n### 修复\n\n- **桌面外壳 Wails Runtime 422** - 升级 `wails3` CLI 与 `@wailsio/runtime` 客户端到 alpha.92 / alpha.79,匹配 alpha.79 引入的 binding transport refactor(请求改用 JSON body),消除旧 CLI(alpha.40)读 URL query 时的 `missing object value` 422 错误,桌面事件订阅(`desktop:show-tab` / `desktop:tray-error`)和 typed binding 调用恢复正常;同步升级前端工具链 vite 8 / vue-tsc 3 / typescript 6 / vue 3.5。\n- **空/畸形 Tool Call 自动重试** - 在 Fuzzy 模式下将空参数或非法 JSON 的 tool/function call 视为空响应并复用现有 failover,降低上游偶发 `Read({})` 等畸形工具调用对下游客户端的影响\n - 影响模块:Messages/Chat/Responses/Gemini 的非流式空响应判定、Messages/Responses 流式预检、Chat 流式写头前缓冲预检\n\n## [v2.7.4] - 2026-05-18\n\n### 修复\n\n- **Mimo thinking mode 兼容性** - 修复 mimo(小米 MiMo)等 reasoning model 上游在多轮对话切换场景下返回 400 \"The reasoning_content in the thinking mode must be passed back to the API\" 的问题\n - 之前实现将 thinking 块转为顶层 `reasoning_content` 字段(OpenAI 风格),实测 mimo 的 Anthropic 协议下不认顶层 `reasoning_content`,仍返回 400\n - 新实现保留所有原有 thinking 块,对缺少 thinking 块的 assistant 消息注入占位 thinking 块 `{\"type\":\"thinking\",\"thinking\":\"(no prior reasoning recorded)\"}`,让 mimo 通过 thinking mode 校验\n - 仅在渠道开启 `passbackReasoningContent: true` 时生效,对其他渠道无影响\n - 注意:旧对话切到 mimo 时模型上下文缺少真实推理内容,可能出现幻觉/指令遵循下降(mimo 官方公告明示的代价)\n- **OpenAI Provider 缓存 Token 丢失** - 修复 OpenAI Provider 在协议转换(OpenAI/DeepSeek → Claude Messages)时丢弃上游 cache usage 字段的问题(#76)\n - 流式:`HandleStreamResponse` 现在从 terminal usage chunk(`choices: []`)中提取 `prompt_cache_hit_tokens`、`prompt_tokens_details.cached_tokens` 等字段,并注入到 final `message_delta.usage` 中\n - 非流式:`ConvertToClaudeResponse` 新增二次 raw parse,将 DeepSeek/OpenAI 格式的 cache 字段映射到 `CacheReadInputTokens`\n - Metrics:`annotatePromptTokensTotalForProvider` 扩展到 OpenAI Provider,确保缓存命中归一化口径与 Responses Provider 一致\n\n## [v2.7.3] - 2026-05-18\n\n### 优化\n\n- **Docker 构建优化** - 使用 Go 交叉编译替代 QEMU 模拟,优化多架构镜像构建流程并增加层缓存,显著缩短 CI 构建时间\n\n### 修复\n\n- **Mimo reasoning_content 回传修复** - 修复 `convertThinkingToReasoningContent` 在将 thinking 块提取为 `reasoning_content` 字段时未从 content 数组中移除 thinking 块的问题;当从 Claude 原生渠道切换到 mimo 渠道时,残留的 thinking 块导致 mimo 上游返回 400 \"The reasoning_content in the thinking mode must be passed back to the API\" 错误\n\n## [v2.7.2] - 2026-05-17\n\n### 新增\n\n- **缓存读写总统计** - 全局统计图表和模型统计图表新增缓存 Token 读写统计展示\n - 后端:`ModelHistoryDataPoint` 新增 `cacheCreationTokens`/`cacheReadTokens` 字段;`GetModelStatsHistory` 和全局统计的模型分桶聚合逻辑补充缓存 Token 累加\n - 前端 `GlobalStatsChart`:Summary cards 和 compact summary 新增缓存 R/W 统计(有数据时显示);Tokens 图表视图动态添加 Cache Read/Write 系列线\n - 前端 `ModelStatsChart`:新增 Cache 视图切换,展示按模型分组的缓存 Token 趋势\n - 前端 `api.ts`:`ModelHistoryDataPoint` 类型补充缓存字段\n\n## [v2.7.1] - 2026-05-17\n\n### 新增\n\n- **渠道统计对齐接入点统计** - 渠道 Key 趋势图新增 7d/30d 时间维度,并展示总请求次数、成功率、输入/输出 Token 汇总卡片,与接入点总览统计对齐 (#72)\n - 后端:`HistoryDataPoint` 补充 Token 字段;`MetricsHistoryResponse` / `ChannelKeyMetricsHistoryResponse` 新增 `summary` 汇总;Key 趋势接口移除 24h 上限,支持 30 天范围 SQLite 聚合\n - 前端:`KeyTrendChart` 新增 7d/30d 按钮、summary cards、长时间范围 x 轴日期格式\n - SQLite:新增 `idx_records_api_type_metrics_key_timestamp` 复合索引优化渠道级长范围查询\n\n### 改进\n\n- **Override 熔断自动清除** - 当驾驶舱设置的 override(next channel)序列中所有渠道均不可用(熔断)时,调度器自动清除该 override 而非仅跳过,避免前端 NEXT 标签长期残留\n- **渠道熔断状态下发前端** - `GetConversationChannelsByKind` API 返回的渠道信息新增 `circuitOpen` 字段,前端驾驶舱可实时显示渠道熔断状态(FUSED 标记)\n\n### 修复\n\n- **Claude 协议空 Text Block 兼容开关** - 修复严格校验的第三方 Claude 协议上游因 Claude Code 在 `tool_use` 前插入裸空 `{\"type\":\"text\",\"text\":\"\"}` 占位块而返回 400 的兼容性问题;新增 Messages 渠道 `stripEmptyTextBlocks` 开关,在转发前按需移除空 text block,并同步接通前端配置、渠道视图与回归测试。\n- **Responses SSE keep-alive** - 为 Responses 流式代理增加 SSE keep-alive 机制,每 15 秒向下游发送 `: keepalive` 注释行,防止 DeepSeek 等慢上游思考期间触发 Codex 客户端 idle timeout 断连 (#67)\n\n## [v2.7.0] - 2026-05-17\n\n### 新增\n\n- **Responses Compact 本地压缩** - 当 responses 渠道上游为非原生 Responses 类型(openai/claude/gemini)时,`/v1/responses/compact` 端点自动切换为本地 compact 模式:将对话历史格式化为 transcript,通过现有 converter 管线发送普通请求让模型生成摘要,再包装为 Responses 格式返回。支持流式/非流式跟随客户端、session 历史读取与 compact 结果写回、大输入截断保护。原生 responses 上游若返回 404/405/501 也会自动回退本地 compact\n- **SessionManager 新增只读查询与压缩会话创建** - 新增 `GetSessionByResponseID` 通过 responseID 只读查找 session;新增 `CreateCompactedSession` 创建压缩后的轻量会话并记录映射\n- **ResponsesProvider 提取公共请求构建方法** - 新增 `ConvertBodyToProviderRequest` 公共入口,接受 bodyBytes 参数复用现有 URL/转换/认证逻辑,供 compact 等场景调用\n\n### 改进\n\n- **Messages 渠道模型列表兼容国内 Claude 协议入口** - 当 `base_url` 以 `/anthropic`、`/claude`、`/messages` 结尾时(如 `https://api.deepseek.com/anthropic`),模型列表获取自动尝试三段候选 URL:当前路径 → 剔除协议尾段 → 纯域名根路径,解决国内服务商模型接口不在兼容协议子路径下的问题。管理端\"获取模型\"同步适配。\n\n---\n\n## 历史版本(v2.0.0-go ~ v2.6.99)\n\n> 以下为早期版本的里程碑摘要,`v2.7.0` 及之后保留完整条目。更细粒度的历史变更可通过 `git log` 查看。\n\n### v2.6.x(2026-02 ~ 2026-05)\n\n- **渠道能力测试** - 完成 4 协议互转、Codex 自定义工具兼容、RPM/超时/取消/重定向验证、结果缓存与多协议并发轮询。\n- **独立 Images 渠道** - 新增 `/v1/images/generations`、`/v1/images/edits`、`/v1/images/variations`,接入独立 failover、metrics、日志和前端管理。\n- **熔断器三态状态机** - 引入 `closed` / `open` / `half_open`、指数退避、单探针恢复、持久化和 Dashboard 状态展示。\n- **渠道日志生命周期** - 支持 `pending`、`connecting`、`first_byte`、`streaming`、`completed`、`failed`、`cancelled` 全链路追踪与前端实时刷新。\n- **会话调度看板** - 新增 Conversation Dashboard、ConversationTracker、OverrideManager 与按对话覆盖渠道序列能力。\n- **Responses Compact 本地压缩** - 非原生 Responses 上游自动通过 converter 管线执行本地 compact,并写回轻量会话。\n- **Vision 能力路由** - 新增 `noVision`、`noVisionModels`、`visionFallbackModel`,按图片输入动态跳过或降级模型。\n- **Thinking / Reasoning 兼容** - 支持 DeepSeek、MiMo、Gemini 等上游的 `thinking`、`reasoning_content`、`thought_signature` 双向转换和回传。\n- **Codex 工具兼容层** - 支持 Codex `apply_patch` 代理工具、字符串工具、native tool passthrough、工具调用回写和 `codexToolCompat` 统一开关。\n- **模型与路由能力增强** - 支持模型过滤包含/排除规则、`X-Channel` 指定目标渠道、渠道级代理、自定义请求头、多 BaseURL 等价去重。\n- **缓存与指标口径统一** - 归一 Responses / Messages 缓存命中率、Token usage、Key / Model 维度统计和历史分桶。\n- **自动恢复机制** - 支持 UTC 0/8/16 时段自动恢复黑名单 key、half-open 探测和 suspended 渠道最小激活。\n- **前端体验与性能优化** - 完成多语言管理界面、移动端导航、能力测试弹窗重构、SVG/Tooltip 常驻资源收敛和内存优化。\n- **关键修复** - 覆盖 Fuzzy 模式 5xx 误判、空响应 failover、metadata.user_id JSON 对象兼容、401 字符串认证错误拉黑、客户端取消误计失败等问题。\n\n### v2.5.x(2026-01)\n\n- **Failover 逻辑模块化** - 提取通用 failover 分类、重试和黑名单判定逻辑。\n- **Gemini 兼容增强** - `thought_signature` 可配置,补齐 Gemini CLI tools schema 与 Dashboard 字段。\n- **指标记录架构优化** - 修复二次计数、客户端取消计数、换 key 后历史累计、渠道删除后串桶等问题。\n- **渠道管理增强** - 支持渠道置顶/置底、前端模型映射智能选择、隐式缓存读取推断。\n- **关键修复** - 包括 Gemini functionDeclaration parameters 类型、Models API 日志标签、响应 model 字段改写可配置化。\n\n### v2.4.x(2025-12 ~ 2026-01)\n\n- **Gemini API 完整实现** - 新增 Gemini API 模块、路由、历史指标和前端管理界面。\n- **统计图表增强** - 新增全局流量 / Token 图、Key 趋势图、缓存命中率统计和多模型趋势展示。\n- **低质量渠道处理机制** - 支持 `lowQuality` 渠道标记、暂停清理促销期和输出验证日志。\n- **配置模块化拆分** - 按渠道类型拆分 config 包,减少单文件配置膨胀。\n- **多端点调度优化** - 支持多 BaseURL failover、预热排序、非阻塞动态排序和并发竞争修复。\n- **关键修复** - 覆盖流式工具调用分裂、空 signature 导致 Claude API 400、内容审核无限重试、403 预扣费降级、SSE 事件完整性等问题。\n\n### v2.3.x(2025-12)\n\n- **渠道级 API Key 策略与多 BaseURL** - 支持多 key / 多 URL 管理、促销期状态展示和延迟测试优化。\n- **SQLite 指标持久化** - 引入本地指标存储,补齐 Responses API Token usage 与 Messages 缓存 TTL 细分统计。\n- **快速添加渠道** - 支持协议自动检测、引号 / 等号 / 空格分割、`settings.json` 解析和多 Base URL 输入。\n- **Models API 端点** - 新增模型聚合查询能力并完善 API Key / Base URL 去重。\n- **关键修复** - 包括滑动窗口重建、Responses usage 缺失、缓存 token 检测、total_tokens 零值补全。\n\n### v2.2.x ~ v2.1.x(2025-12)\n\n- **Handlers 模块重构** - 将 handlers 重构为同级子包结构,完善 Stream 错误处理与 CountTokens 安全加固。\n- **指标系统重构** - 指标绑定到 Key 级别,修复熔断器、单渠道路径指标和非 failover 错误计数。\n- **错误处理与历史数据** - 新增 Fuzzy Mode 错误处理开关、渠道指标历史数据 API、Key 级使用趋势图和失败率可视化。\n- **协议兼容增强** - 支持 TransformerMetadata、CacheControl、BaseURL `#` 结尾语义和 FinishReason 统一映射。\n- **稳定性修复** - 覆盖请求体大小限制、goroutine 泄漏、数据竞争、优雅关闭、流式日志合成器类型等问题。\n\n### v2.0.x(2025-10 ~ 2025-12)\n\n- **Go 版本初始发布** - 完成核心功能移植、单文件部署和性能提升。\n- **核心组件落地** - 引入 ChannelScheduler、MetricsManager、TraceAffinityManager 等基础模块。\n"}, "e972d82e6b4b3edf23f948edbd0db2d4c80db655": {"t": 1785809899.296053, "n": 114679, "h": "734953b7098f3dc6", "c": "## [Unreleased]\n\n### 新增\n\n- **订阅中心与密钥管理体验** - 订阅中心改为渠道中心式 Provider 快捷接入,支持一键添加 GitHub Copilot、new-api 与手动订阅;三家赞助商展示真实 Logo/徽标并移除 Unity2.ai;API Key 支持手动暂停/恢复,模型覆盖按 Key 集合归组,火山折叠摘要及 Kimi/Compshare/MiniMax 余量改为统一五档阈值着色,Kimi 同时展示订阅总用量、赠送额度和加油包余额\n- **Autopilot Trace v2 契约与脱敏边界** - 新增 TraceDetailV2/TraceSummary/SchedulerDecisionSummary/EndpointAttemptSummary DTO,ComparisonStatus 三态枚举(matched/mismatched/uncompared)替代裸 bool,SanitizeForPersistence/SanitizeForResponse 双边界脱敏,crypto/rand 碰撞安全 TraceUIDv2,策略指纹(排除比例/seed),稳定哈希分桶,v1→v2 读适配器;新增 40+ L1 表驱动测试\n- **SQLite v6 迁移、持久化与清理** - autopilot schema v5→v6 幂等迁移;trace 表新增 schema_version/trace_revision/request_correlation_id/release_id/policy_fingerprint/persistence_class/details_json 列;窗口表重建主键增加 release/policy/cohort 维度和 compared/matched/mismatch/uncompared 计数;safety event 增加 release/policy 字段;in-flight 索引和异常提升落盘;有界异步 writer(512 队列/128 终态预留/64KiB 上限/250ms deadline/1s drain);7 天 trace + 30 天窗口清理策略\n- **Trace 关联接入请求生命周期** - 入口生成 requestCorrelationId 写入 gin context;选择渠道后写入 AutopilotTraceUID;ChannelLog 新增 RequestCorrelationID/AutopilotTraceUID 字段和 ChannelLogOption;BuildPlan 写入 dry-run trace(必落盘);NormalizeSelectionTrace 将 SelectionTrace 规范化为安全摘要并 AttachSchedulerDecision;AppendEndpointAttempt 有序/容量受限/乱序合并的 attempt 摘要\n- **endpoint attempt 摘要接入实际请求链路** - SetAttemptRecorderHook + recordAttemptCompleted hook 模式;upstream_failover 在 CreatePendingLog 后记 \"started\",成功/错误时记 \"completed\";main.go 注册 hook 连接 TraceStore;fail-open 观测失败不影响代理请求\n- **受控发布与自动降级** - active 模式加入配置枚举/归一化/IsAutopilotActive;RolloutPercent/ControlPercent/ReleaseID/RolloutSeed 配置字段和 validateRollout 校验;ReleaseController 集中管理状态迁移(逐级晋升/降级随时允许)、稳定分桶、EvaluateAndApplyRegression 三窗口回归降级、SafetyOverride 安全覆盖;接入 SmartRouter 请求路径(入口冻结 RoutingReleaseSnapshot);release/policy/cohort 隔离聚合\n- **只读 Trace API 与前端详情** - GET /api/autopilot/traces/:traceUid 详情端点(404/503/partial 契约、2s 查询 deadline);列表 API 改为 TraceSummary 输出,支持 release/cohort/mode 过滤和游标分页;统计新增三态比较计数;前端 AutopilotTraceDetailDialog 决策→Scheduler→尝试→终态时间线;AutopilotTraceTable 适配 TraceSummary;ChannelLogsDialog 跳转入口\n- **分层测试(L1-L5)** - L1 纯函数 40+;L2 SmartRouter+TraceStore 集成 8;L3 SQLite 生命周期 12(重启还原/坏 JSON/过期清理/迁移幂等/脱敏);L4 HTTP 契约 9(列表/详情/统计/404/脱敏/分页);L5 真实上游 smoke 2(默认 t.Skip);SSE golden 回归 6(帧顺序/trace 不改输出/取消/脱敏)\n- **Autopilot 模型与思考强度联合路由** - 自动路由的决策单元从「模型」提升为「模型 + 思考强度」的原子组合,解决模型被改写为异构上游后仍沿用客户端原档位、落在从未评测过的能力点上的问题。新增 `ResolvedRouteTarget` 作为贯穿全链路的唯一决策载体;`NormalizeEffortLevel` 统一证据侧/能力侧/请求侧的厂商档位命名,`IsUnpinnedEffort` 使 `default` 类未固定档位证据只能作降级 fallback 而非冒充精确匹配;`rankEligibleModels` 按 `SupportedEffortLevels × PerTaskClass` 展开 model×effort 候选,`betterRankedModel` 加入「够用即止」平手规则防档位膨胀,`filterEffortFloor` 按任务类下界过滤且全被过滤时 fail-open;`upstream_failover` 原子改写 model + 思考参数(任一步失败整体放弃),含 `adaptive_only` 守卫与 Gemini `thinkingConfig.thinkingLevel` 原生注入路径;`ManualRoutingIntent` 新增可选 `Effort` 字段(未知值校验拒绝,客户端显式 off/none 优先于意图锁定);`ChannelLog` 新增 `effortDecisionSource`/`effortClampedByClient`(仅在客户端上限实际生效时置位)与 Trace `effort` 维度;`PreferAutoOverManual` 开关支持手工映射优先级反转(默认关闭,可零代码回滚),新增 `GET /health-center/profile-coverage` 只读画像覆盖率诊断作为手工表退役前置门槛\n- **火山 Agent Plan AFP 成本感知路由** - 新增火山 AFP 政策目录与纯计算器、套餐作用域解析与可比较性判断、AFP 请求快照与候选级成本证据;AFP 折扣接入 SavingsScore 参与评分并写入 trace,compshare 单次减扣同样纳入;新增 `frontierRoutingEnabled` / `afpCostRoutingEnabled` 两个正交开关,AFP 成本路由默认开启\n- **确定性 Pareto 分层与三倾向候选阶梯** - 候选排序改为确定性 Pareto 分层,输出成本/质量/均衡三倾向阶梯;模型候选排序重排并接入 CostPreference\n- **渠道预检异步化与后台 discovery 断点续传** - 渠道预检不再阻塞添加流程,后台 discovery 支持断点续传\n- **多协议自动发现补全** - 补全多协议自动发现并自动启用已发现协议路由;自动发现改从 `apiKeyConfigs` 取密钥;协议探测改为逐模型验证;快速探活改为多模型候选策略(含回退测试);自定义协议渠道构造统一\n- **发现 RPM 应用与账号级 429 scoped cooldown** - 接线自动发现出的 RPM 到运行态限速,429 冷却收敛到账号级作用域\n- **连接耗时采集驱动 SpeedTier** - 新增连接建立耗时指标并参与速度分级\n- **弃用参数自动发现与记忆落盘** - 上游不支持的参数按渠道-Key-模型维度自动发现并剥离,记忆落盘后重启免重新探测\n- **渠道兼容性开关统一自学习** - 六项兼容性开关(`NormalizeNonstandardChatRoles`/`StripImageGenerationTool`/`CodexNativeToolPassthrough`/`StripEmptyTextBlocks`/`PassbackReasoningContent`/`PassbackThinkingBlocks`)从「用户手工勾选的静态布尔」彻底收归「运行时按渠道-Key-模型自动学习并持久化的能力事实」:字段已从 `UpstreamConfig`/`UpstreamUpdate` 整体删除,管理面板不再提供编辑入口,渠道更新接口不再接受手工写入,用户此后无需手工干预。新增 `ChannelCompatCache`(落盘 `.config/channel_compat.json`,TTL 24h,键 `channelUID:keyHash:model`)统一记忆六项兼容能力,最终生效值按 `学习结论 > 历史种子 > 静态默认` 合成。老用户配置文件里残留的历史手工值不代表可信的长期事实(可能当初就设错,也可能上游后来有了新情况),因此启动时 `migrateManualCompatSwitchesToSeeds` 从磁盘原始 JSON 读出历史值,一次性降级为渠道级、TTL 14 天的一次性低置信度种子(`CompatSeeds`):迁移当下不改变可感知行为,但学到真实结论后立即让位,过期后也不再参与判断;种子 TTL 与学习记忆 TTL 分开存储(14 天 vs 24 小时),避免混淆。该迁移按数组下标把原始 JSON 对到当前渠道,因此必须排在 `mergeManagedProviderAccounts` 之前——后者会把同 provider 的多账号渠道合并成一条(数组变短重排),顺序颠倒会让种子写到错误渠道或随被合并渠道丢失;`mergeManagedProviderAccounts` 同时补上了合并时携带 `CompatSeeds`,避免被合并掉的渠道的历史证据丢失。`Clone()` 一并深拷贝 `LearnedCompatTraits`,避免克隆已注入结论的 upstream 时共享 map。按信号强度分三条学习路径:有硬 400 信号的走 `CompatTraitFromError` 错误驱动(识别后同 Key 立即重试,改写在下一轮注入本次请求专属的 `LearnedCompatTraits`),其中 Codex `/v1/responses` 打到不支持 `developer` role 的上游时新增 `DowngradeDeveloperRoleToSystem` 窄改写(降 `system` 而非 `user`,保留开发者指令语义);仅能靠响应形态判断的 `passbackReasoningContent`/`stripEmptyTextBlocks` 复用渠道诊断探针,改为首次遇到该组合时 singleflight 后台自动探测一次,替代原先要用户手工点按钮;图片生成受限从「直接拉黑 (Key,模型)」改为「先学习剥离图片工具并重试,剥离后仍失败才回落拉黑」。所有识别规则采用双条件门控(正则命中 + 请求侧确实携带对应结构),且 429/5xx/超时一律不参与兼容性学习,避免把容量问题学成能力缺失。`RuntimeUpstreamForAutoManagedProvider` 连种子一并清空,自动托管渠道的兼容性完全由厂商原生默认加运行时学习决定\n- **Kimi 参数硬约束迁入 model-registry 刷新链路** - Kimi K3/K2.7-code/K2.6 的 temperature/top_p/n 固定值、tool_choice/thinking 约束不再是本地硬编码前缀表,改为 `UpstreamModelCapability.ParamConstraints`,随 `shared/model-registry/ccx_model_registry.json` 走 presetstore 的定期刷新链路发布;运营者核对厂商文档后更新 JSON 即可让线上实例在下一个轮询周期生效,不需要重新编译发版\n- **Kimi 按量模型参数硬约束前置规避** - `ApplyKnownParamConstraints` 按厂商文档在发送前规避已知约束,无需先失败一次:`kimi-k3`/`kimi-k2.7-code` 系列/`kimi-k2.6` 的 `temperature`/`top_p`/`n`/`presence_penalty`/`frequency_penalty` 为固定值,传入即 `invalid_request_error`,故直接剥离(上游用自身默认值,语义无损);`kimi-k2.6`/`kimi-k2.7-code` 不支持 `tool_choice: \"required\"`,降级为 `auto`;`kimi-k2.7-code` 的 `thinking` 仅接受 `{\"type\":\"enabled\",\"keep\":\"all\"}`,非法配置归一化为该值。`moonshot-v1` 系列的可改参数不受影响\n- **注册 Claude Opus 5** - 新增 Claude Opus 5 模型注册并支持 4.8 点号别名\n- **模型能力基准数据源扩展** - 接入 Artificial Analysis 数据源并纳入 opus-5;benchlm 改用官方 `models.json` 取代对比页抓取;benchmark-update 增加页面变更检测缓存层;补全 DEEPSWE/BENCHLM/DRADAR 白名单 CCX 模型映射与 kimi-k3、grok-4.5、muse-spark-1.1 等模型,DEEPSWE cohort 扩展至 17 模型;刷新 model registry 至 20260726\n- **trace 与账号信息可读性** - trace 候选渠道展示渠道名与 Key 掩码;账号列表返回脱敏 access token;`UsageMeter-Record` 日志补充渠道/接口/状态上下文\n- **auto 模式 SLO 回归保护可配置** - SLO 回归自动降级改为开关控制\n- **模型清单展示改进** - 共有/协议专属模型少于 10 个时直接展开列出,其余默认两行、仅实际溢出时显示展开\n- **火山套餐 Key 保活 per-key 路由与共享探针** - `config.BaseURLsForKey` 让已绑定端点的 Key 只在自己端点探测,不参与渠道级 BaseURL 笛卡尔积;新增 `internal/upstreamprobe` 共享火山 Agent/Coding Plan 数据面探针(autopilot 验证与 healthcheck 保活共用,避免请求特征漂移);`L1Response.RealCallVerified` 标记真实调用,火山 L1 成功后同周期跳过等价 L2 避免重复消耗额度;L2 探针副本覆盖绑定 BaseURL,recordFailure 归因到 Key 实际绑定端点\n- **渠道级实测上下文上限自学习** - 模型注册表登记的是模型公开窗口,个别渠道对某个模型的实际窗口更短(中转商自行截断、上游按套餐限制),长上下文请求会反复吃 400 `context_too_large`。新增按 渠道-Key-模型 维度记忆实测上限的数值型能力覆盖(`config.ContextLimitState`,复用 `ChannelCompatCache` 键空间/24h TTL/落盘,但独立于布尔型 `CompatTrait` 存储):`ContextLimitFromError` 从 400/422 报错识别超限信号,上游明确声明窗口值时直接采信,仅表示「太长」时按被拒请求量的 7/8 反推保守上界,并排除 `max_tokens`/请求体过大/图片尺寸/配额等相邻错误避免误判;合成规则「宁小勿大」多次学习取最小值,只在遇到更严格证据时收紧,放宽只能靠 TTL 过期重学;`SmartRouter.buildChannelEntry` 取 `min(注册表窗口, 实测上限)` 让上下文硬约束按真实容量判断,路由发生在选定 Key 之前故取该渠道-模型在所有已知 Key 上的最小值。上下文上限按自身 `LearnedAt` 独立计算 TTL(不共用 `entry.DetectedAt`,否则 trait 命中刷新会让实测上限被无限续期);无记忆时 fail-open,反推上界低于 4096 不采信。兼容性记忆单例上移为 `config.SharedChannelCompatCache`,供写入方 handlers 与读取方 autopilot 共享\n\n### 修复\n\n- **兼容性自学习块位于 failover 判断之后导致生产环境从未生效** - `upstream_failover.go` 的弃用参数自适应与渠道兼容性自学习(developer role 降级、Codex 工具剥离等)两个学习块原本写在 `if shouldFailover` 分支之后。而错误分类以「大多数非 2xx 都 failover」为默认策略,未命中已知不可重试关键词的 400/422 会被判定为 `shouldFailover=true` 并提前 `continue`,导致这两个学习块在默认配置下几乎从未被执行到——只有极少数恰好命中窄关键词表的报错才能落到它们。现已移至 `shouldFailover` 判断之前,与上下文上限自学习块位置一致;新增用真实 `TryUpstreamWithAllKeys` 驱动的集成测试钉住「上游 422 → 学习 → 同 Key 重试成功」完整闭环\n- **422 兼容性学习分支不可达** - 兼容性自学习块声称覆盖 400/422,但被嵌套在外层 `resp.StatusCode == 400` 守卫内,422 分支实际永远不可达(部分上游用 422 表达请求结构不受支持);改为与弃用参数块平级的兄弟块——弃用参数识别只针对 400,兼容性学习覆盖 400 与 422,二者条件不同不应嵌套\n- **Chat 入口 Gemini 上游响应被误判为空** - `handleSuccess` 的 `default` 分支承接 gemini 等没有专属 case 的透传上游类型,却统一使用只认识 OpenAI `choices[].message` 结构的 `IsChatResponseEmpty` 判空;对 Gemini 的 `candidates` 结构,`respMap[\"choices\"]` 断言必然失败而被判定为空,导致所有合法 Gemini 响应都误触发 failover、最终以 503 收尾。改为按 `candidates` 字段(Gemini 专属、非 omitempty)区分 schema 后走 `IsGeminiResponseEmpty`;不用 `usageMetadata` 判断是因为该字段为 omitempty,缺省时会误判回 OpenAI 形态\n- **effort 注入形态误判与三处死代码** - 自动托管渠道被 `RuntimeUpstreamForAutoManagedProvider` 清空 `ReasoningParamStyle` 后,`effortInjectionStyle` 一律回落 `reasoning` 对象形式,导致 Claude messages 渠道被写入 `reasoning.effort`、chat 渠道被写成对象,上游均不识别而静默丢弃 effort;改为按渠道类型推导原生形态(messages→`thinking`,chat→`reasoning_effort`)。同时修复三处「代码存在但调用链不通」的死代码:`CapabilityFloor.EffortFloor` 从未被赋值致 `filterEffortFloor` 恒直接返回,改为在 `rankEligibleModels` 内按 `ReasoningEffortConfig.PerTaskClass` 推导;`resolveRelativeBenchmarkEvidence` 的 `targetEffort` 恒传空串致 `(domain, effort)` 精确匹配与置信度折算永不执行,新增 `ResolveDomainStrengthForEffort` 并在候选展开处按变体实际档位取证据;`extractClientEffort` 协议无关全字段扫描与按渠道分支的 `ExtractClientEffortExplicit` 对跨协议字段判定相反,导致手动意图 effort 被静默屏蔽而日志记录未钳位,删除前者统一走后者。另补齐 messages 分支的 `thinking.effort` 提取(此前 `type=enabled` 只返回 `enabled` 占位而非真实档位)\n- **精确模型命中绕过 effort 决策** - `ResolveModel` 的精确/等价模型命中路径直接返回,从未调用 `resolveEffortVariants`,导致请求模型能在渠道内精确匹配时(最常见路径)手动意图锁定档位与自动展开全部失效,仅跨模型替换才走 effort 逻辑;抽出 `resolveSingleProfileEffort` 在两处分支补齐,语义与排序路径一致(意图锁定优先,否则按配置展开取最低档)\n- **effort 钳位语义与 Gemini 档位提取** - 修正 effort 钳位语义并在 failover 时重置标记;Gemini effort 提取取真实档位而非占位值,`targetByUID` 写入条件统一;`ResolvedRouteTarget` 定义与 `upstream_failover` 旧 API 保留兼容 fallback\n- **火山套餐 Key 识别与恢复** - 优先识别火山 Agent Plan 的 Kimi K3;按火山额度重置时间恢复被拉黑的 Key;`ListArkAgentPlanModel` 签名 scope 从 `ark_stg` 改回 `ark`\n- **渠道列表与诊断展示错位** - 渠道列表第二个渠道不再错显首个渠道的请求数与缓存率;渠道排序不再随 Key 拉黑变动;重新发现协议后同步刷新渠道列表;自定义渠道协议发现结果正常展示;路由过滤原因与诊断面板改用渠道名和热门模型替代 `ch_xxx` 内部 ID;Key 与健康状态同步修复\n- **驾驶舱空白** - 意图列表 nil slice 被序列化成 `null` 导致驾驶舱整页空白\n- **system 角色归一化破坏缓存边界** - providers 归一化 system 角色时保留 `cache_control` block 边界,避免打断上游 prompt 缓存\n- **billing header 域名判断忽略端口号** - 带端口的自建地址不再被误判为非官方域名,新建渠道默认值跟随 baseUrl\n- **保活验证失败无痕迹** - healthcheck 保活验证失败写入渠道日志\n- **连接延迟慢速判定误报** - 样本数不足 20 时 P95 退化为最大值导致误判慢速,新增最小样本门槛\n- **new-api 地址与图标注册** - new-api 地址自动剥离控制台路径;补齐 `mdi-github`/`mdi-plus-circle`/`mdiPause`/`timer-alert-outline`/信息提示轮廓等缺失图标注册;简化延迟告警芯片文案\n- **基准与预置数据修正** - litellm 映射补齐 26 个模型定价并去重 Kimi 会员条目;AA evidence 补 `taskCount` 修复 TS 类型校验;presetstore 支持多量纲基准原始值;区分 kimi-k3 的 256k/1m 变体上下文;清理渠道配置中过时的 grok 精确模型映射\n- **RoutingReleaseSnapshot 字段缺失** - 补全 `RolloutSeed`/`ControlPercent`,避免灰度分桶快照失真\n- **火山套餐 Key 跨套餐误探测** - 混合套餐渠道中 Agent Plan Key 不再被首地址 `/api/coding` 误打导致 auth_failed 误拉黑;火山官方套餐入口不再调用通用 `/v1/models`(套餐 Key 无法通过该接口探测),改用专用探针 + 内置 manifest 模型清单;400/404/500/网络错误不升级为认证失败\n- **渠道全部 Key 禁用时无恢复入口** - `hasOnlyDisabledChannelApiKeys` 判定(可用=0 且禁用>0)下渠道行直接显示恢复按钮,一次点击恢复全部禁用 Key,无需先暂停;active 渠道跳过冗余 `setStatus(active)`,直接 resume 并刷新\n- **非官方域名默认剔除 billing header** - `stripBillingHeader` 未显式设置时按渠道 BaseURL 推断默认值:仅当渠道所有地址均为 Anthropic 官方 API(`api.anthropic.com`)时保留计费 header,其余第三方渠道默认剔除;此前默认关闭,导致 Claude Code 每次请求变化的 `cch=` 计费 nonce 打穿第三方上游的 prompt 缓存前缀,造成缓存命中率异常下降\n\n### 重构\n\n- **移除 Fuzzy 模式开关,统一为单一 failover 策略(BREAKING)** - Fuzzy 模式早已是唯一实际使用的默认值,Normal 模式的精确状态码分类分支已无存在意义,保留两套逻辑徒增维护成本。删除 `FuzzyModeEnabled` 配置字段、`GetFuzzyModeEnabled`/`SetFuzzyModeEnabled`、`GET/PUT /api/settings/fuzzy-mode` 接口及其配置迁移逻辑;`shouldRetryWithNextKeyNormal` 与 `classifyByStatusCode` 整体删除,`shouldRetryWithNextKeyFuzzy` 重命名为 `shouldRetryWithNextKey` 成为唯一实现;`HandleAllChannelsFailed`/`HandleAllKeysFailed` 不再接收 `fuzzyMode` 参数,统一返回通用 503;各协议 `handleSuccess` 的空响应拦截(`IsClaudeResponseEmpty` 等)不再受 `fuzzyMode` 门控而无条件生效;Web 与桌面前端一并移除工具栏开关、stores 状态、API 封装与 i18n key。**行为变化**:404/405/409-417/422/423/424/426/428/431/451 与 3xx 不再走「不 failover 直接透传上游原始状态码与错误体」路径,这类错误会消耗全部 Key/渠道后以通用 503 收尾;全部渠道失败时不再透传最后一个上游错误详情(`model_not_found` 等可归一化的模型路由错误仍保留精确状态码透传)\n- **订阅中心精简** - 移除分区标题、能力标签与手动添加入口,补齐快捷接入文案;赞助商置顶、new-api 置底,GitHub Copilot 卡移到倒数第二位\n- **移除渠道菜单的试用意图入口**\n- **清理火山套餐历史分支** - 移除 `volcenginePlanModelsHost` 常量与 `endpointFor` 的 `ark_stg` 分支\n\n### 优化\n\n- **展示格式统一** - 统一所有订阅套餐余量展示格式并补全火山\"剩余\"前缀;组合展示统一为单空格分隔;渠道日志不再展示调度摘要原始串\n- **后端代码质量** - 清理 Go lint 问题,gofmt 对齐结构体字段与注释缩进\n\n### 测试\n\n- **Autopilot 分层测试补齐** - L2 SmartRouter + TraceStore 集成、L3 SQLite 生命周期、L4 HTTP 契约、L5 真实上游 smoke 与 SSE golden 回归\n- **回归与稳定性** - 快速探活多模型候选回退测试;healthcheck `per_key_test` flaky 记录断言修复\n- **基准断言同步** - 同步模型能力基准断言,且不再硬编码 registry 刷新日期与分值\n\n### 文档\n\n- **Autopilot 计划与设计** - 新增 trace 灰度实施计划、火山 AFP 感知路由计划、火山套餐 Key 保活恢复计划、渠道异步发现方案,并补充 model+effort 联合决策现状与进度\n- **配置与基准说明** - 火山 provider 模板补充 AFP 路由说明;`make help` 补充 `ARTIFICIAL_ANALYSIS_API_KEY` 用法;量化 Kimi HighSpeed 配额消耗为 3 倍;修正 `canonicalModelToPattern` 的 kimi 分支注释\n\n### 其他\n\n- **停止跟踪 upstream-check 运行时状态文件**\n- **重新生成 model registry 产物**\n\n## [v3.0.0] - 2026-07-23\n\n### 新增\n\n- **Claude Code System Header 过滤** - OpenAI/Gemini/Responses provider 自动过滤 CC billing header、identity 与 subagent 角色描述,减少上游噪音;StripBillingHeader 扩展为完整移除 billing block\n- **分层 system header 过滤接入请求流程** - Messages 入口按 channel-keyHash-model 记忆最优过滤层级,连接错误与 HTTP failover 失败时渐进升级,成功后巩固层级;仅影响 Claude 透传入口,其余 provider 保持原有过滤\n- **托管渠道模型清单重新发现** - 编辑渠道对话框为托管渠道提供重新发现按钮,触发 auto-discover 后轮询任务状态并自动刷新模型清单,支持 zh-CN/en/id 三语\n- **Kimi Code 套餐用量查询** - Kimi 自动托管凭证支持绑定本地 Web 会话令牌,查询并刷新周额度、短期频限、订阅与赠送余额;绑定、刷新与账号列表接口均不回显令牌\n- **DeepSeek 官方账号自动托管** - 快速添加同时创建 Messages、Chat 与 Responses 协议渠道,模型发现统一使用官方 `/models`,编辑渠道时可按凭证查询余额\n- **DeepSeek 峰谷成本倍率配置** - Autopilot 成本评分支持按供应商、时区和时间窗口应用倍率;内置规则延后至 2026-07-20 激活,可通过配置调整生效时间\n- **Autopilot 规范模型能力基准** - 模型注册表新增独立 benchmark 领域向量,按渠道质量证据向下折算并写入路由 trace;首批覆盖 Claude Opus 4.8、GPT-5.6 Terra 与 GPT-5.6 Sol\n- **Autopilot 手动 L3 供应商质量探测** - 新增固定 canary、每日预算与 endpoint×model 画像回写,支持 Messages、Chat、Responses、Gemini 上游协议,响应和持久化均不包含明文 Key 或模型原始输出\n- **new-api 多账号管理** - 支持为已有 new-api 订阅添加多个账号(accessToken),后端提供账号 CRUD API,渠道编辑页面可管理账号列表\n- **Autopilot 智能路由引擎(Phase 1-4 全部完成)** - SmartRouter 评分引擎(§5.3 九项公式 + §5.5 派系偏好 + §5.6 价格偏向)、确定性 TaskClassifier + 路由配置/kill switch、shadow 注入与 assist/auto 模式语义转正、EndpointAttemptPolicy(§4.6.2)+ TryUpstreamWithAllKeys、CapabilityFloor + MinQualityTier reasons、enforce model routing policy boundaries、接入模型注册表能力下界与上下文窗口硬约束、Phase 1 收尾接线、Phase 2 剩余5项集成、P1.5 迁移与回滚契约\n- **Autopilot 画像与健康诊断系统** - ProfileStore(内存缓存 + SQLite 异步持久化)、Profiler L1 被动画像推导、HealthAnalyzer L1 被动健康诊断、后台聚合 worker、健康中心只读 API 与前端视图、LocalModelRuntimeProfile 本地运行时画像与探测、LocalCandidateProvider 进入 endpoint 候选池\n- **Autopilot TrustedRoutingAdvisor 与决策追踪** - 五态状态机 + AdvisorHintEffect 路由约束解析、shadow 评估 + 决策记录 + API、画像变更事件推送与前端时间线(Phase 3A)、RoutingDecisionTrace + TraceStore + trace handlers、智能路由诊断面板与自动模型映射诊断预览\n- **Autopilot 时序与限流感知** - TimeBucketMetrics + QualityTrend 检测(§3.10)、RateLimitDiscoverer shadow(§4.5)、GroupChangeDetector 分组变更检测(§3.9)、FastDecay shadow 评分 + 批量查询 + RPM 运行态注入、接线限流信号/时序/分组变更到 Manager 与健康中心 API\n- **Autopilot L2 探测与模型映射** - L2 轻量探测 worker + 每日预算 + 探测队列、模型自动映射闭环(Phase 3B-2)、StabilityTier 晋降级滞后(Phase 3B-3)、L2 探测自愈闭环(Phase 3B-1)、自动发现完成后一致结果自动写入 SupportedModels\n- **Autopilot 订阅与用量画像** - SubscriptionProfile + 驾驶舱聚合 API + 三 store 接线、§3.2.3 订阅级能力共享 + §3.2.4 用量窗口 Phase 1 shadow、订阅中心 provider adapter(余额自动刷新)\n- **Autopilot 人工路由意图** - ManualRoutingIntent(Phase 1 shadow)、参与 SmartRouter 路由(§4.6.4 人工意图执行语义)\n- **Autopilot 真实请求画像与自适应超时** - 基于真实请求画像、TTFB 自适应响应头超时、采集端点响应头延迟画像、跨重启保留 TTFB 画像\n- **Autopilot auto 上线闸门与自动降级** - auto 上线闸门、自动降级、终态渠道降至 assist 队尾\n- **Autopilot A/B 测试** - 低比例统计抽样双发、ABTestConfig.Validate() 补齐负值/越界兜底\n- **Autopilot SLO regression 自动回滚**\n- **Autopilot 本地任务模板系统**\n- **Autopilot 用户自定义标签系统** (Phase 4 Item 1)\n- **Autopilot 成本报表** - 按用户/模型/渠道/key (Phase 4 Item 2)\n- **Autopilot 渠道推荐** (Phase 4 Item 4)\n- **Autopilot 批量渠道管理** - 导入/导出/模板 (Phase 4 Item 5)\n- **Autopilot new-api 订阅集成** - 后端 adapter + 前端\n- **Autopilot 火山方舟套餐用量查询与展示** - 未绑定 Access Key 时回退内置兜底模型清单,兜底模型清单区分 Agent Plan 与 Coding Plan\n- **Autopilot MiniMax Token Plan 用量查询** - MiMo 绑定控制台 Cookie 查询 Token Plan\n- **渠道自动探测与聚合** - 自动探测并聚合多协议渠道、在编辑页展示协议可用模型、自动识别发现协议类型与展示自动探测协议地址\n- **智谱官方渠道自动识别** - 自动识别智谱官方渠道模板,补齐桌面端渠道自动识别模板\n- **快速添加渠道 Provider 模板化** - 选 provider + 输 key 即可创建、per-key baseURL 探测与绑定\n- **多协议渠道统一展示** - 统一展示多协议渠道、按上游协议展示渠道标签、稳定多协议渠道排序\n- **渠道保活验证模块** - 新增渠道保活验证、渠道健康检查配置与持久化\n- **(Key,模型) 组合级限制与定时自动恢复** - failover 新增组合级限制,定时自动恢复\n- **内置模型清单增强** - /v1/models 补充 max_output_tokens (#266)、同步 LiteLLM 能力与 DeepSWE 证据\n- **模型能力基准数据自动更新** - benchmark 自动更新模型能力基准数据\n- **优云智算套餐余量查询** - compshare 绑定优云智算套餐余量查询、同步套餐并发限速\n- **Kimi Code 模型与按 Key 发现**\n- **火山引擎 Agent 与 Coding Plan 自动识别**\n- **自动托管账号持久化层级** - 账号级凭证接口、切换凭证差异更新、聚合旧版托管协议渠道\n- **new-api 安全分组倍率路由**\n- **远程预置更新运行时** - 预置数据集中管理 + 运行时可更新架构(Phase 1)、完成远程预置更新运行时\n- **订阅来源等级只读展示** - 来源等级由来源类型系统推导,改为只读展示\n- **火山引擎与 BytePlus 赞助**\n- **套餐类渠道 Key 行余量摘要补齐多时间窗**\n- **桌面端 autopilot 对齐** - 管理面板+健康中心+骨架(第一波)、cockpit概览+new-api订阅+试用意图(第二波)\n- **桌面端渠道自动探测与复用同步** - 同步渠道自动探测与复用、托管渠道识别模板与徽章、Codex 生图工具权限探测、TTFB 首字节 P95 列、auto 上线闸门与自动降级到桌面端健康中心\n- **Responses 音频 content block 丢弃告警** - converters: Responses 音频 content block 丢弃时输出告警日志\n- **前端 Health Center / Subscriptions / Cockpit 视图**\n- **渠道卡片健康 badge + 来源标签系统(§8.2)**\n\n### 修复\n\n- **本地部署渠道重新发现后显示\"来源未知/历史记录未保存\"** - `writeProfiles` 与 `buildEndpointInventory` 统一用 `CanonicalBaseURL` 规范化 baseURL,修复带 `/v1` 后缀时 endpointUID 不一致导致画像写入后无法被 `ListActiveByChannel` 命中的问题\n- **套餐型 Provider Key 限额重置后不自动恢复** - Kimi/MiMo/优云智算/火山用量刷新后新增 `TryRestoreDisabledKeysByUsage`,按各 Provider 用量快照的重置时间与剩余额度自动恢复因余额/限额不足被拉黑的 Key;同时修复 `syncManagedAccountsFromChannels` 仅从活跃 Key 重建凭证池、导致被拉黑托管 Key 的 Console token/AccessKey/用量快照在下次配置保存时丢失的问题\n- **Provider 多协议账号部分写入** - 首次创建与旧账号补协议改为验证后单次提交,失败不残留半成品渠道,并保留 MiMo 区域及火山套餐端点亲和性\n- **快速添加成功后对话框未关闭** - provider 自动托管创建成功后立即结束交互,模型发现继续在后台执行\n- **Autopilot 配置迁移与运行态隔离** - 旧配置块解析或升级失败时回退安全默认值,支持结构体指针叠加,并避免环境急停经管理 API 写回配置文件\n- **辅助路由协议回归** - `assist` 保留无法评分的原候选供 failover,真实路由 trace 回填实际渠道,修复模型不支持错误重试、普通 Key 的模型限制执行、relay 耗尽误限制及 Messages 响应模型字段丢失\n- **new-api 验证失败** - 修复某些 new-api fork(如 Veloera/voapi)要求 `New-API-User` header 导致的 401 错误,新增 VerifyWithFallback 两阶段验证回退逻辑\n- **套餐 Key 恢复边界条件收紧** - P1 收紧恢复条件为 AND 语义、P2 修复 Kimi/火山套餐恢复边界条件\n- **system header 过滤稳定性修复** - 修复 panic、过期误判与误升级问题、字符串形式 system 未过滤 CC header 的问题\n- **Autopilot 替代模型选择优化** - 按任务难度选择替代模型、优先选择更强同档模型、使用渠道模型倍率排序、按能力下限优化排序、避免常规任务过度升级模型、修正多模态模型能力路由\n- **Autopilot 模型发现与画像修复** - 记录模型发现来源与时间、自适应模型解析修正、endpoint metrics identity 对齐、profile model support decisions 遵守、当前有效画像清单对账\n- **Autopilot 路由约束对齐** - scheduler diagnose 与 request profile 对齐、shadow 与自动路由约束对齐、dry-run 与自动路由决策对齐、诊断画像与路由追踪统一\n- **Autopilot 配置迁移与隔离修复** - 自动升级旧版 Autopilot 配置、配置迁移隔离完善、环境急停经管理 API 写回配置文件隔离\n- **火山方舟 Agent Plan 修复** - 探针与协议入口区分、Key 端点验证修复、套餐 Key 验证请求特征对齐、Coding Plan 用量刷新修复、套餐模型发现签名修复\n- **渠道展示与编辑修复** - 托管渠道真实模型范围展示、额度禁用状态与用量展示修正、渠道行展示与官方渠道命名/官网编辑优化、渠道添加与官方渠道编辑流程简化\n- **自动托管渠道编辑精简** - 多轮精简自动托管渠道编辑项、收敛编辑项\n- **Autopilot 端到端流程修复** - auto-add/auto-status 端到端流程、辅助路由协议回归、终态渠道降至 assist 队尾\n- **MiMo 自动发现修复** - 自动发现状态修复、自动发现失败修复、自动添加多协议渠道\n- **桌面端同步修复** - 额度禁用状态与 Key 计数、baseUrl 清理路径、自动渠道命名简化\n- **前端图标注册修复** - mdi-check-all、DeepSeek 余额图标、mdi-connection、ApiKeyManagementSection 缺失 mdi 图标、官方渠道标识图标\n- **前端 TypeScript 编译错误修复**\n- **Compshare system roles 规范化** - messages: normalize Compshare system roles\n- **LLM 统一渠道视图置顶/置底排序修复**\n- **模型定价同步与未配置成本标记**\n- **重新发现轮询识别 failed 状态并展示错误**\n- **拉取渠道模型列表时使用 key 级绑定 baseUrl**\n- **模型能力基准多来源更新与图表生成修复**\n- **熔断引入模型多样性门槛与渠道组合失败熔断**\n- **自动托管渠道官网地址持久化**\n- **快速添加渠道名称预览修复**\n- **最终上游模型日志记录** - logging: record final upstream model\n- **内部元数据泄漏到续轮请求阻止** - responses: 阻止内部元数据泄漏到续轮请求\n- **快速添加前去重 API Key**\n- **渠道自动命名简化与仪表盘轮询合并**\n- **容器后端验证的前端嵌入修复**\n- **baseUrl 清理剔除 /api-keys 创建页路径**\n- **disabled managed credentials 跳过** - routing: skip disabled managed credentials\n- **Claude Code system 指纹与客户端探针特征对齐**\n- **小屏协议地址布局优化与协议名称换行避免**\n- **添加弹窗正文滚动与 usage 控制台路径识别**\n- **Autopilot 按真实协议创建自定义渠道**\n- **Autopilot DeepSeek 官方账号托管完善**\n- **逻辑渠道多协议日志汇总**\n- **按客户端协议持久化画像分类与发现完成后立即持久化**\n- **绑定模型发现结果保留**\n- **官方 provider 凭证池复用与自动托管官方渠道身份标明**\n- **按 endpoint binding 隔离模型调度** - routing: 按 endpoint binding 隔离模型调度\n- **旧版托管协议渠道聚合**\n- **官方渠道改用智能调度**\n- **快速添加成功后关闭弹窗**\n- **content_part 流式事件识别** - responses: 识别 content_part 流式事件\n- **schema 迁移幂等自愈 advisor_decisions.reason 列**\n- **scoreEndpointForKey EndpointUID 与 handlers 层不一致修复**\n- **RateLimitApplier kill switch 测试闭包捕获值拷贝问题**\n- **Responses input item additional_tools 剥离**\n- **未知健康状态徽标颜色调整**\n- **快速添加上游类型选择移除**\n- **黑名单密钥恢复状态提示**\n\n### 重构\n\n- **订阅中心重构为 Provider 卡片式快捷接入面板** - 移除订阅列表表格展示,改为双栏布局:左侧 Provider 卡片(GitHub Copilot/new-api/手动添加),右侧详情表单;订阅中心只负责接入新订阅\n- **Agent skills 迁移至 .claude/skills** - 通过 symlink 将 agent skills 迁移到 .claude/skills 目录\n- **协议模型可用性 diff 视图重新设计** - frontend: 重新设计协议模型可用性差异视图\n- **Kimi 用量展示简化** - frontend: 简化 Kimi 用量展示以匹配官方 CLI\n\n### 优化\n\n- **路由性能优化** - config: clone only selected upstream;routing: cache request channel snapshots、prefilter incapable managed channels、prefilter channels without usable keys\n\n### 文档\n\n- **Autopilot 设计文档** - 新增同订阅多 Key 能力共享/配额负载均衡(§3.2.3)与用量窗口(§3.2.4)设计、任务域优势矩阵(§5.7)与思考等级差异(§5.8)设计、内置模型清单设计(§4.2.1)、new-api 集成与订阅 Provider Adapter 框架、Phase 3A/3B/4 全部完成状态更新\n- **桌面端文档** - 补充后端发现与端口附着约定\n- **Codex 上游协议变更评估** - 追加 Codex rust-v0.145.0 上游协议变更评估、新增 gpt-5.6 适配与 codex imagegen 上游文生图待办\n- **SignPath 代码签名致谢** - 文档站首页与 README 添加 SignPath 代码签名致谢,logo 放大并链接到官网\n- **Star History 图表更新** - 调整为 Markdown 图片并更新 token\n- **火山引擎推广链接** - README 为火山引擎注册即领 2500 万 Tokens 添加推广链接\n\n### 其他\n\n- **Windows 签名切换至正式证书策略** - ci: 切换 Windows 签名至正式证书策略\n- **Serena 项目配置更新**\n- **Autopilot 日志降噪** - 降低画像落盘成功日志噪声、移除画像推导冗余日志\n- **Codex 0.143.0 upstream check 记录**\n- **Apple Container 隔离验证** - 添加 Apple Container 隔离验证环境\n- **下载按钮改为两行网格布局** - style: 下载按钮改为两行网格布局\n\n## [v2.9.37] - 2026-07-07\n\n### 新增\n\n- **渠道选择 Trace 全链路诊断** - scheduler: 新增 channel selection trace,覆盖候选过滤、活跃模型过滤、选择失败原因,支持 `/v1/models/diagnose` 干跑视图与流式日志摘要\n- **响应续思折叠** - responses: 上游截断的 reasoning continuation 在代理层自动折叠,避免客户端收到不完整思考链\n- **渠道配置发现增强** - discovery: 增强渠道配置发现能力,改进推荐算法\n- **瞬态渠道发现** - discovery: 新增瞬态渠道发现机制,支持快速探测上游可用性\n- **断路器温和策略改为新默认** - circuit-breaker: 调整断路器策略预设,温和策略取代激进策略作为新默认值\n- **Agnes 模型描述注册** - registry: 为 Agnes 系列模型补充描述信息\n- **store-update 完成后自动打开下载目录** - skill: store-update 下载完成后自动在文件管理器中打开目标目录\n\n### 修复\n\n- **discovery 工具调用探测协议选择错误** - discovery: messages 渠道 serviceType 为 responses/chat/gemini 时,工具调用能力探测始终用 Claude 格式导致假阴性;修复为按 serviceType 自动选择 responses/chat/gemini 对应格式\n- **discovery 不再生成 supportedModels 通配白名单** - discovery: 发现流程自动写入 \\*alias\\* 通配符模式(\\*opus\\*、\\*gpt\\* 等)到 supportedModels,实际上渠道无需此限制;删除生成逻辑,apply 时始终清空旧值\n- **discovery 协议探测失败时模型映射为空** - discovery: 用户显式选定 channelKind 但该协议探测失败时,buildDiscoveryMappingRecommendation 返回空映射;降级到任意成功协议的模型重新构建别名映射\n- **thinking probe 误覆写 passbackReasoningContent** - compat: 历史 thinking block 探测返回 400/422 时错误地将 passbackReasoningContent 置为 false;此标志由独立 thinking 预算探测决定,不受历史块探测影响\n- **协议延迟统计含超时模型** - discovery: runDiscoveryProtocolProbe 对所有模型(含超时)的延迟做均值,导致有效延迟数据被拉高;改为仅统计成功模型均值\n- **gemini 渠道 reasoningMapping 暂不生成** - discovery: Gemini handler 目前未消费 reasoningMapping,写入配置只产生无效噪声;明确不为 gemini 渠道生成该字段,待 Gemini thinking 路径支持后再启用\n- **compat evidence 迭代顺序不稳定** - discovery: map 迭代顺序不确定导致证据列表每次不同;改为先排序 key 再遍历\n- **熔断器未将 no_available_account 计为过载** - failover: 上游返回 no_available_account 未触发熔断,新增 FailureClassOverloaded 分类\n- **serviceType 派生协议回退优先** - discovery: 当用户未显式指定 channelKind 时,优先使用 serviceType 派生的协议作为回退,而非仅依赖探测成功模型\n- **回退成功模型未传播到后续探测** - discovery: 首次协议探测成功得到的模型在后续探测中被忽略,导致其他协议探测缺少候选;改为将回退成功模型注入所有后续探测\n- **目标协议失败时未从回退协议构建映射** - discovery: 目标协议探测完全失败时 buildDiscoveryMappingRecommendation 返回空;改为从任意成功协议的模型重新构建别名映射\n- **发现流程白名单生成污染 supportedModels** - discovery: 自动写入通配符模式到 supportedModels 实际上限制了渠道可见模型;删除生成逻辑,apply 时始终清空旧值\n- **messages 渠道工具调用探测格式错误** - discovery: 工具调用能力探测始终用 Claude 格式;修复为按 serviceType 自动选择 responses/chat/gemini 对应格式\n- **显式渠道协议推荐被覆盖** - discovery: 用户明确指定 channelKind 时协议推荐仍被探测结果覆盖;改为优先保留用户选择\n- **malformed 响应未触发重试** - failover: 上游返回 malformed 响应体时未触发重试机制;新增重试逻辑\n- **Vue SFC 模块类型声明缺失** - desktop: 前端 typecheck 无法识别 .vue 文件模块声明;补充 env.d.ts 中的 SFC 模块声明\n- **桌面端开发模式未加载嵌入前端资源** - desktop: Wails dev 模式未正确加载 embed.FS 中的前端资源\n- **Wails 开发服务器日志过于冗杂** - desktop: 抑制 Wails dev server 信息级别日志输出\n- **.vue 导入 TS2307 类型错误** - frontend: tsconfig 未包含 env.d.ts 导致 .vue 文件导入报 TS2307\n- **模型列表未暴露上下文窗口** - models: /v1/models 返回结果未包含 context window 信息\n- **模型聚合等待窗口过短** - discovery: 多上游模型聚合的等待窗口不够长,部分慢响应渠道被遗漏\n- **discovery 重复创建 channel models fetcher** - discovery: 每次发现流程重新创建 fetcher 实例,改为复用已有实例\n- **上游临时过载未触发熔断** - backend: 上游返回 503/429 等临时过载状态码时未正确触发断路器\n- **未配置模型时 /v1/models 返回空** - models: 当模型未在上游注册时,回退到渠道配置的模型列表\n- **discovery 通道推荐 UI 对齐** - discovery: 通道推荐 UI 展示对齐设计稿\n- **上下文能力菜单被遮挡** - frontend: 上下文能力下拉菜单被其他元素遮挡\n- **渠道 URL 包含 profile wallet 路径** - frontend: 前端展示渠道 URL 时未剥离 profile wallet 路径前缀\n- **渠道 URL 包含 admin 路径** - frontend: 前端展示渠道 URL 时未剥离 /admin 路径前缀\n- **桌面端上下文模型下拉层级错误** - desktop: 上下文模型下拉菜单被高级选项遮挡,提升层级\n\n### 优化\n\n- **对齐 model registry map keys 格式** - style: 统一模型注册表 map keys 对齐方式,移除测试文件多余空行\n\n### 文档\n\n- **Channel Autopilot 设计文档** - docs: 新增 Channel Autopilot 自动路由设计文档\n- **Channel Autopilot 端点级粒度设计** - docs: 细化 Channel Autopilot 到端点级别的路由粒度设计\n- **Channel Autopilot 时间质量感知** - docs: 为 Channel Autopilot 设计补充时间维度质量感知机制\n- **Channel Autopilot 手动路由意图** - docs: 新增手动路由意图设计,支持用户显式指定路由偏好\n- **Channel Autopilot 成本感知路由** - docs: 新增成本感知路由设计,在质量与成本间自动权衡\n- **Channel Autopilot 原生 image/vector 路由** - docs: 将原生 image 与 vector 渠道纳入自动路由范围\n- **可信本地路由顾问** - docs: 新增可信本地路由顾问设计,基于历史数据提供路由建议\n- **用户价格偏向 (Cost Preference) 设计** - docs: 新增用户价格偏向设计,支持按成本偏好调整路由权重\n- **上游供应商质量差异设计 (ProviderQualityScore)** - docs: 补充上游供应商质量差异评分设计\n- **模型派系偏好 (Model Family Preference) 设计** - docs: 新增模型派系偏好设计,支持用户指定偏好的模型家族\n- **ModelFamily 枚举扩展至 17 个派系** - docs: 模型派系枚举扩展至 17 个,来自模型注册表\n- **Channel Autopilot 验收契约** - docs: 新增 Channel Autopilot 验收测试契约文档\n\n### 改进\n\n- **保留用户显式选定的渠道协议推荐** - discovery: 用户明确指定 channelKind 时优先保留别名,不受协议探测结果覆盖\n\n## [v2.9.36] - 2026-07-05\n\n### 新增\n\n- **集成 vectors Embedding 兼容性面板到桌面端渠道编辑流程** - desktop: 集成 vectors Embedding 兼容性面板到渠道编辑流程\n- **新增 vectors Embedding 兼容性配置面板组件** - desktop: 新增 vectors Embedding 兼容性配置面板组件\n- **移植 embedding 兼容性类型/序列化/i18n 到桌面端** - desktop: 移植 embedding 兼容性类型、序列化与 i18n 到桌面端\n- **OpenAI 响应思考标签提取为原生 reasoning 字段** - provider: OpenAI 响应思考标签提取为原生 reasoning 字段\n\n### 修复\n\n- **对齐桌面端 capability 测试反馈** - desktop: 对齐桌面端 capability 测试反馈\n- **使用结构化对话轮次** - cockpit: 使用结构化对话轮次替代纯文本\n- **embedding 兼容性配置边界问题** - vectors: 修复 embedding 兼容性配置边界问题\n- **剥离 embedding 请求体中的 stream 字段** - vectors: 剥离 embedding 请求体中的 stream 字段避免上游 SSE 误触发\n\n### 优化\n\n- **提取 buildEndpointURL 正则为 package-level var** - vectors: 提取 buildEndpointURL 正则为 package-level var 避免重复编译\n\n### 其他\n\n- **补齐 embedding capability 与 handler 边界测试** - test: 补齐 embedding capability 与 handler 边界测试\n\n## [v2.9.35] - 2026-07-04\n\n### 新增\n\n- **展开态当前渠道点击支持固定与取消固定切换** - cockpit: 展开态当前渠道点击支持固定与取消固定切换\n- **快捷渠道覆盖支持固定与取消固定** - cockpit: 快捷渠道覆盖支持固定与取消固定\n- **发布成功后自动调用 store-update** - github-release: 发布成功后自动调用 store-update\n\n### 修复\n\n- **向量化渠道兼容性配置边界问题** - vectors: 修复桌面端 Embedding 兼容性面板未指定 normalized 时崩溃、Vectors 渠道重名更新、非法 embeddingCapabilities 保存与非法 input 本地校验缺口\n- **拦截 Claude Code 模型切换探针避免空响应误判** - messages: 拦截 Claude Code 模型切换探针避免空响应误判\n- **修复 copilot 渠道 /v1/models 代理返回 404** - models: 修复 copilot 渠道 /v1/models 代理返回 404\n- **修正 gitignore 规则并添加 git 代理自动检测** - store-update: 修正 gitignore 规则并添加 git 代理自动检测\n\n### 其他\n\n- **同步 skill 更新到 .agents/skills/** - chore: 同步 skill 更新到 .agents/skills/\n\n## [v2.9.34] - 2026-07-04\n\n### 新增\n\n- **注册 mdiCash 图标** - ui: 注册 mdiCash 图标,补充前端图标资源\n- **5xx + Retry-After 也触发限速 cooldown** - ratelimit: 上游返回 5xx 或 Retry-After 头时同样触发限速 cooldown\n- **上游临时过载检测与渠道冷却机制** - failover: 新增上游临时过载检测与渠道冷却机制,提升故障转移鲁棒性\n\n### 修复\n\n- **单模型渠道 CostUSD 不显示** - metrics: 修复单模型渠道 CostUSD 不显示的问题\n- **限速窗口单位语义修正** - ratelimit: 修正限速窗口单位语义,字段值直接以秒为单位\n\n### 其他\n\n- **Serena 本地记忆目录加入忽略列表** - chore: 将 Serena 本地记忆目录加入 gitignore\n\n## [v2.9.33] - 2026-07-04\n\n### 修复\n\n- **DeepSeek context_management 不兼容导致 400** - providers: 过滤 DeepSeek Claude 兼容端点不支持的 `context_management.clear_thinking_20251015` 编辑和 `context-management-2025-06-27` beta header,修复 Claude Code 首轮/recap 请求的 thinking 回传 400\n\n## [v2.9.32] - 2026-07-03\n\n### 修复\n\n- **ChannelManager canDeleteChannel 引用错误** - desktop: 修复 ChannelManager canDeleteChannel 引用错误\n\n## [v2.9.31] - 2026-07-03\n\n### 新增\n\n- **DeepSeek tool_use passback 双重降级缓存** - thinking: DeepSeek tool_use passback 双重降级缓存\n\n### 修复\n\n- **opaque encrypted_reasoning 占位保障 DeepSeek thinking passback** - responses: opaque encrypted_reasoning 占位保障 DeepSeek thinking passback\n\n## [v2.9.30] - 2026-07-03\n\n### 新增\n\n- **原生 WebSocket v2 透传** - responses: 支持原生 WebSocket v2 透传\n- **流式 session 回写与 incomplete 合成** - responses: 流式路径 session 回写并在 post-commit stall 时合成 incomplete\n- **Responses reasoning encrypted_content 缓存** - thinkingcache: 扩展存储 Responses reasoning encrypted_content\n- **分层保真 compact** - compact: 增加分层保真 compact 与 function_call_output 摘要\n\n### 修复\n\n- **快速创建渠道数据源提交** - desktop: 统一快速创建渠道时提交数据源\n- **视觉回退上下文校验** - failover: 校验 vision fallback context,避免无效视觉回退上下文继续传播\n- **agent skills 路径与 Store MSIX tag 下载** - skill: 修复 `.agents/skills` 内 skill 文档误指向 `.claude/skills` 的路径,并确保 store-update 脚本的 `--tag` 会传递给 `gh release download`\n\n### 其他\n\n- **desktop-store-update 技能清理** - skill: 删除 desktop-store-update 技能并更新工具配置\n- **agent skills 同步** - skill: 同步 agent skills 文档、脚本和 upstream-check 状态\n\n## [v2.9.29] - 2026-07-03\n\n### 新增\n\n- **token 成本(USD) 统计** - metrics: 新增 token 成本(USD) 统计\n- **loadShed reaper 与 cooldown 软跳过改进** - scheduler: loadShed reaper 自动推进到期状态、cooldown 软跳过与 scoped limiter 清理改进\n- **preflight 空响应详情诊断与敏感信息脱敏** - stream: preflight 空响应详情诊断与敏感信息脱敏\n\n### 修复\n\n- **MiniMax M2 系列上下文与输出元数据** - model-registry: 修正 MiniMax M2 系列上下文与输出元数据\n- **火山方舟 quota exceeded 识别与临时限流误判** - failover: 识别火山方舟 quota exceeded 并修复临时 token-per-minute 限流误判\n- **桌面渠道模型映射下拉框显示** - desktop: 修复渠道模型映射下拉框显示并清理调试日志\n\n### 文档\n\n- **Microsoft Store submission API 凭据获取说明** - skill: 补充 Microsoft Store submission API 凭据获取说明\n\n### 其他\n\n- **desktop-store-update 技能** - skill: 新增 desktop-store-update 技能\n\n## [v2.9.28] - 2026-07-02\n\n### 新增\n\n- **BIND_HOST 环境变量支持** - server: 新增 BIND_HOST 环境变量以绑定指定网络接口\n- **Claude thinking 回填缓存** - thinkingcache: 新增 Claude thinking 回填缓存模块\n- **子代理精确识别** - agent: 通过 X-Claude-Code-Agent-Id 精确识别子代理\n- **统一配置管理与 CLI 选项** - thinkingcache: 统一 thinking 缓存配置管理与 CLI 选项\n- **DeepSeek Claude thinking 缓存诊断** - compat-diagnose: DeepSeek Claude 渠道启用 thinking 缓存诊断\n- **DeepSeek Claude 预设 thinking 缓存模式** - preset: DeepSeek Claude 预设切换到 thinking 缓存模式\n\n### 修复\n\n- **stop_reason 仅事件误判** - stream: stop_reason 仅事件不再误判为语义内容\n\n### 重构\n\n- **override 日志增强** - scheduler: 增强 override 日志输出\n\n## [v2.9.27] - 2026-07-02\n\n### 新增\n\n- **渠道预设 maxTokens 支持** - desktop/preset: 渠道预设新增 maxTokens 字段并支持桌面端配置\n- **渠道 preset authHeader 字段** - preset: 渠道 preset 新增 authHeader 字段\n- **thinking-only 响应识别** - stream: preflight 支持 thinking-only 响应识别为有效内容\n- **订阅 Tab 优化与 preset 清理** - desktop: 订阅 Tab 优化与 preset channel_types 清理\n- **LongCat-2.0 与 Step 3.7 Flash 模型注册** - models: 新增 LongCat-2.0 与 Step 3.7 Flash 内置模型注册\n\n### 修复\n\n- **轮询立即触发并统一活跃 job 收集** - capability: 修复轮询调度延迟与活跃 job 收集逻辑\n- **兼容上游裸 JSON 行 SSE 流** - claude: 兼容上游裸 JSON 行 SSE 流\n\n### 重构\n\n- **ENV 字段移至服务器配置组** - desktop: 将 ENV 字段移至服务器配置组 PORT 之后\n- **SSE 流式规范化重构** - claude: 重构 SSE 流式规范化并补齐响应必需字段\n- **openai-code-v2 channel_types 移至测试层** - capability: 将 openai-code-v2 channel_types 移至测试层\n\n### 其他\n\n- **Windows MSIX 打包图标资源** - build: 添加 MSIX 打包所需多尺寸图标资源\n\n## [v2.9.26] - 2026-07-01\n\n### 新增\n\n- **Copilot 代理支持** - copilot: OAuth 流程支持代理地址并优化超时处理,支持通过代理进行 Copilot token 交换,新增 Copilot 渠道默认 BaseURL 自动回填\n- **Copilot 订阅中心** - desktop: 新增订阅中心页面,将 Copilot OAuth 从渠道创建移至独立 Tab,支持多账号 key 池管理、多渠道目标与诊断结果展示\n- **Copilot 预设与前端适配** - desktop/preset: 新增 GitHub Copilot 预设并支持 proxyUrl 字段;web/frontend: Copilot 渠道支持代理地址输入,channelPayload 支持 Copilot 默认 BaseURL\n- **订阅 Tab 账号展示增强** - desktop: 增强订阅 Tab 账号展示与 Copilot 模型获取,支持国际化\n- **Claude Sonnet 5 注册** - models: 注册 Claude Sonnet 5 模型\n- **模型元数据增强** - models: 丰富模型元数据,支持 Anthropic 家族分类,优化模型发现机制并添加缓存降级与 keypool 集成\n- **Claude Desktop 连接探测** - messages: 内置 Claude Desktop 连接探测响应\n- **本地 HTTPS 自签名证书** - tls: 支持本地 HTTPS 自签名证书\n- **Codex 原生 WebSocket 协议** - responses: 支持 Codex 原生 WebSocket 协议\n- **商汤日日新接入** - sensenova: 新增商汤日日新 LLM 平台接入\n- **讯飞 Coding Plan 迁移** - preset: 讯飞 Coding Plan 迁移并支持 serviceType 配置\n- **MiMo 计划选择器** - ui: 为 Codex 和 OpenCode 添加 MiMo 计划选择器\n- **超时控件优化** - ui: 将超时控件暴露为秒级显示\n- **macOS 应用内更新** - desktop: 新增 macOS 应用内更新打包支持\n- **会话预览** - ui: 添加可展开的会话预览功能\n- **渠道 Key 复用显示** - desktop: 渠道 Key 复用时显示当前值\n- **image_generation 探测** - diagnose: 探测上游 image_generation tool 支持\n- **渠道编辑交互优化** - channel-editor: 优化渠道编辑交互体验\n\n### 修复\n\n- **copilot serviceType 映射** - capability: 修复 copilot serviceType 映射到 responses 协议\n- **Copilot 订阅 token 复用** - desktop: 修复 Copilot 订阅保存 token 复用与渠道状态管理\n- **copilot BaseURL 清空** - desktop: 修复 copilot 渠道 BaseURL 因空版本前缀被错误清空的问题\n- **normalizeMetadataUserId 协议限制** - config: 将 normalizeMetadataUserId 限定为仅 Messages 协议生效\n- **TLS 证书路径提示** - tls: 证书路径为相对路径时增加绝对路径提示\n- **responses 渠道 normalizeMetadataUserId** - channel-editor: responses 渠道默认关闭 normalizeMetadataUserId\n- **Copilot OAuth 超时上下文** - copilot: 补充 OAuth 请求超时与错误上下文\n- **模型点击排队** - capability-tests: 修复桌面端模型点击排队问题\n- **provider key 资产匹配** - channel: currentAsset 按 planID 精确匹配 provider key 资产\n- **keysByProvider 去重** - preset: keysByProvider 去重保留首个 asset\n- **plan-scoped key 存储** - configservice: plan-scoped key 存储不覆盖 provider channel key\n- **Responses URL 重复拼接** - api: Responses URL 末尾已含 endpoint 时避免重复拼接\n- **MiMo reasoning effort** - api: 规范化 MiMo Responses reasoning effort 上限\n- **Codex tool_search 剥离** - api: 剥离 Codex tool_search 工具调用\n- **compaction 流** - api: 允许 compaction 仅含 usage 的流式响应\n- **MiMo token plans** - desktop: 允许 MiMo token plans 用于 responses 协议\n- **brew deprecated syntax** - brew: 替换已废弃的 `depends_on` 字符串语法\n- **模型能力同步延迟** - ui: 编辑期间延迟模型能力同步\n- **thinking reasoning 参数** - api: 规范化 thinking reasoning 参数\n- **MiMo thinking style** - channel: MiMo 预设使用 thinking style\n- **渠道高级选项布局** - ui: 改善渠道高级选项布局\n\n### 重构\n\n- **ResolveTokenWithProxy** - handlers: 全量替换 Copilot ResolveToken 为 ResolveTokenWithProxy\n- **渠道编辑器 composables 拆分** - channel-editor: 接入 useEditChannelModal 主 composable,提取禁用 Key 恢复/模型获取/Payload 构建、表单选项/派生计算/预设/工具函数、UI 辅助 composables\n- **应用 composables 提取** - app: 提取 useToasts + useAppController composable,提取 savedProviderKeyForPlan 按 planID 匹配资产 key\n- **ChannelEditDialog composables** - desktop: 提取 ChannelEditDialog composables\n- **configservice 瘦身** - configservice: service.go 瘦身为纯调度层,提取 Provider Key 管理、预览/试运行、OpenCode 配置、Codex 配置与会话迁移、路径解析与 Claude 配置、file/toml/jsonc 格式辅助函数\n- **渠道自定义参数面板分离** - ui: 将渠道自定义参数面板拆分为独立组件\n\n### 文档\n\n- 补充桌面端与环境变量文档\n- 清理已完成的上游版本评估条目\n- 添加 Claude Code 2.1.196 上游版本审查\n- 澄清快速模式使用成本说明\n\n### 测试\n\n- 覆盖 Codex tool_search 剥离测试\n\n## [v2.9.25] - 2026-06-29\n\n### 新增\n\n- **Copilot 授权码一键复制** - channel: 为 Copilot 授权码添加一键复制操作,便于完成 OAuth 配置\n\n### 修复\n\n- **提示浮层不被容器裁剪** - ui: 保持 tooltip 显示在被裁剪容器之上\n- **桌面端熔断器弹窗布局** - desktop: 保持熔断器弹窗布局在正常文档流中,避免内容溢出错位\n- **移除全局历史图片限制控件** - ui: 移除全局 historical image limit 控件,避免与渠道级配置混淆\n- **历史图片限制改为渠道级配置** - config: 将 historical image limit 调整为渠道级配置\n\n### 重构\n\n- **抽取 Copilot OAuth 路径常量** - channel: 提取 Copilot OAuth 路径常量并修复授权码清理逻辑\n\n### 文档\n\n- **澄清渠道级图片限制变更** - changelog: 澄清历史图片限制已改为渠道级配置\n\n## [v2.9.24] - 2026-06-29\n\n### 新增\n\n- **MiMo 预设思考强度配置** - channel: 将 MiMo 预设的思考强度配置学习到一键预设\n\n### 修复\n\n- **Volc Ark coding 端点预设** - desktop: 修正 Volc Ark coding 端点预设配置\n- **渠道日志与 Key 错误展示** - desktop: 改善渠道日志和 key 错误信息的显示\n- **认证区域置于重定向前** - channel: 将认证配置区域放在重定向区域之前\n- **Copilot OAuth 外部链接** - desktop: 修复 Copilot OAuth 链接在桌面端无法打开的问题\n\n### 其他\n\n- **延长桌面端 macOS 公证超时** - release: 延长桌面端 macOS 公证任务超时时间\n\n## [v2.9.23] - 2026-06-29\n\n### 修复\n\n- **剥离不支持的 Claude thinking blocks** - responses: strip unsupported Claude thinking blocks\n- **优化 Claude 兼容性诊断** - compat: refine Claude compatibility diagnosis\n- **刷新熔断窗口近期历史** - metrics: refresh breaker windows from recent history\n\n### 测试\n\n- **稳定 thinking 兼容性覆盖** - responses: stabilize thinking compatibility coverage\n\n### 其他\n\n- **延长 macOS 公证超时时间** - release: extend macOS notarization timeout\n\n## [v2.9.21] - 2026-06-28\n\n### 新增\n\n- **渠道日志复制操作** - channel: 为渠道日志添加复制操作,便于快速复制日志内容排查问题\n- **GPT-5.6 能力探测与渠道选项** - capability: 接入 GPT-5.6 能力探测与渠道配置选项\n- **GPT-5.6 Amazon Bedrock 模型定义** - model-registry: 新增 GPT-5.6 Amazon Bedrock 模型定义\n\n### 修复\n\n- **保留 Claude 主 API Key** - desktop: 修复桌面端配置同步时 Claude 主 API key 被覆盖或丢失的问题\n- **Base URL 哈希诊断校验** - 修复 base url hash 诊断校验逻辑,提升问题定位准确性\n- **桌面端流式超时默认值** - desktop: 对齐并保留 stream timeout 默认值\n- **渠道菜单浮层稳定性** - frontend: 稳定渠道操作菜单 overlay 展示,避免交互抖动\n- **Copilot OAuth 国际化文案** - desktop: 补齐 Copilot OAuth 桌面端多语言文案\n- **输出 token 按模型限制裁剪** - routing: 根据模型能力限制下调超限输出 token,避免上游参数错误\n- **能力探测携带必需 thinking 参数** - capability: 强制思考模型探测时自动携带 thinking/reasoning 参数,避免能力误判\n- **Kimi K2.7 输出限制修正** - model-registry: 将 Kimi-K2.7-Code 最大输出与推荐输出修正为 32768\n\n## [v2.9.20] - 2026-06-27\n\n### 新增\n\n- **展示 Claude Code recap** - cockpit: 展示 Claude Code recap\n- **记录并展示 Claude Code recap** - cockpit: 记录并展示 Claude Code recap\n\n### 其他\n\n- **限制 macOS 公证任务时长** - release: 限制 macOS 公证任务时长\n- **并行化 macOS 签名公证流程** - release: 并行化 macOS 签名公证流程\n\n## [v2.9.19] - 2026-06-27\n\n### 新增\n\n- **为对话驾驶舱补充悬浮提示** - ui: 为对话驾驶舱补充悬浮提示\n\n### 修复\n\n- **strip Codex response metadata passthrough** - strip Codex response metadata passthrough\n- **处理 Responses 流式错误事件** - responses: 处理 Responses 流式错误事件\n- **补齐印尼语缺失翻译** - i18n: 补齐印尼语缺失翻译\n- **跳过 Claude 空输出重试标题** - backend: 跳过 Claude 空输出重试标题\n- **remove channel logs refresh button** - desktop: remove channel logs refresh button\n\n### 重构\n\n- **抽取 Responses WebSocket 回退处理器** - backend: 抽取 Responses WebSocket 回退处理器\n\n### 其他\n\n- **配置 macOS 签名公证** - desktop: 配置 macOS 签名公证\n\n## [v2.9.18] - 2026-06-26\n\n### 新增\n\n- **捕获 WebView 前端运行时错误** - desktop: 捕获 WebView 前端运行时错误\n- **支持前端错误写入桌面日志** - desktop: 支持前端错误写入桌面日志\n- **同步渠道预设兼容选项** - channel: 同步渠道预设兼容选项\n- **优化兼容诊断默认建议** - channel: 优化兼容诊断默认建议\n- **展示主对话最新消息** - frontend: 展示主对话最新消息\n- **更新 MiMo V2.5 模型能力** - model-registry: 更新 MiMo V2.5 模型能力\n- **添加兼容性诊断入口** - channel: 添加兼容性诊断入口\n- **添加兼容性诊断** - channel: 添加兼容性诊断\n- **优化驾驶舱展开对话卡片展示** - ui: 优化驾驶舱展开对话卡片展示\n- **添加驾驶舱关联对话导航** - ui: 添加驾驶舱关联对话导航\n- **GitHub Copilot 共享认证支持与安全加固** - GitHub Copilot 共享认证支持与安全加固\n- **支持 Chat/Messages/Gemini 入口转换到 Copilot** - copilot: 支持 Chat/Messages/Gemini 入口转换到 Copilot\n- **优化添加渠道引导与文档** - copilot: 优化添加渠道引导与文档\n- **优化前端诊断结果展示** - copilot: 优化前端诊断结果展示\n- **增加 OAuth 重试/取消与渠道诊断** - copilot: 增加 OAuth 重试/取消与渠道诊断\n- **补齐 ping、错误分类与 OAuth 状态展示** - copilot: 补齐 ping、错误分类与 OAuth 状态展示\n- **支持 GitHub Copilot OAuth 与 Responses 上游接入** - copilot: 支持 GitHub Copilot OAuth 与 Responses 上游接入\n- **ConversationCard 展示子代理列表与状态摘要** - web: ConversationCard 展示子代理列表与状态摘要\n- **支持流式会话状态追踪** - conversation: 支持流式会话状态追踪\n\n### 修复\n\n- **区分主对话与子代理覆盖状态** - ui: 区分主对话与子代理覆盖状态\n- **支持仅子代理渠道覆盖** - backend: 支持仅子代理渠道覆盖\n- **覆盖 Gemini 内联图并加固图片 token 估算** - utils: 覆盖 Gemini 内联图并加固图片 token 估算\n- **按真实尺寸估算内联 base64 图片的 token** - utils: 按真实尺寸估算内联 base64 图片的 token\n- **跳过空 tool_calls 避免 text block 被拆分** - providers: 跳过空 tool_calls 避免 text block 被拆分\n- **移除折叠态子代理摘要** - frontend: 移除折叠态子代理摘要\n- **按展开状态置顶对话卡片** - ui: 按展开状态置顶对话卡片\n- **修正对话卡片展开展示** - ui: 修正对话卡片展开展示\n- **跳过 Claude Command 文档标题回退** - messages: 跳过 Claude Command 文档标题回退\n- **优化对话卡片展开展示** - ui: 优化对话卡片展开展示\n- **同步单轮主对话预览截断** - desktop: 同步单轮主对话预览截断\n- **优化单轮主对话预览截断** - frontend: 优化单轮主对话预览截断\n- **拆分驾驶舱主对话轮次展示** - frontend: 拆分驾驶舱主对话轮次展示\n- **提供全局提示组件上下文** - desktop: 提供全局提示组件上下文\n- **优化驾驶舱对话 ID 展示** - ui: 优化驾驶舱对话 ID 展示\n- **优化驾驶舱窄屏工具栏布局** - desktop: 优化驾驶舱窄屏工具栏布局\n- **优化渠道搜索框响应式宽度** - channel: 优化渠道搜索框响应式宽度\n- **优化驾驶舱窄屏筛选布局** - frontend: 优化驾驶舱窄屏筛选布局\n- **添加搜索框清除按钮** - desktop: 添加搜索框清除按钮\n- **优化驾驶舱筛选栏布局** - frontend: 优化驾驶舱筛选栏布局\n- **优化驾驶舱对话展示** - frontend: 优化驾驶舱对话展示\n- **精简驾驶舱统计卡片** - frontend: 精简驾驶舱统计卡片\n- **修正子代理总数统计** - frontend: 修正子代理总数统计\n- **过滤重复对话卡片** - frontend: 过滤重复对话卡片\n- **过滤重复渠道索引** - frontend: 过滤重复渠道索引\n- **修正驾驶舱卡片与统计文案** - frontend: 修正驾驶舱卡片与统计文案\n- **延迟加载全局统计图** - desktop: 延迟加载全局统计图\n- **保留快速输入清理后的 URL** - frontend: 保留快速输入清理后的 URL\n- **修正快速输入 URL 清理** - frontend: 修正快速输入 URL 清理\n- **新建/编辑渠道默认开启自动学习上游限速** - frontend: 新建/编辑渠道默认开启自动学习上游限速\n- **用 unref() 包装 store 响应式值** - frontend: 用 unref() 包装 store 响应式值\n- **过滤标题回退中的注入上下文** - backend: 过滤标题回退中的注入上下文\n- **修复托盘开机自启切换状态** - desktop: 修复托盘开机自启切换状态\n- **调整统一会话 ID 提取优先级** - backend: 调整统一会话 ID 提取优先级\n- **更新火山方舟控制台链接** - desktop: 更新火山方舟控制台链接\n- **驾驶舱展开卡片排序稳定** - Web 与桌面端会话驾驶舱在存在展开卡片时固定当前卡片顺序,避免轮询刷新或最新消息更新时间导致卡片跳动;全部收起后恢复按最新消息排序,并补充桌面端排序稳定性测试\n- **兼容 vLLM 响应中的 reasoning 字段** - providers: 兼容 vLLM 响应中的 reasoning 字段\n- **清理会话反馈残留引用** - backend: 清理会话反馈残留引用\n- **移除驾驶舱会话反馈输入框** - ui: 移除驾驶舱会话反馈输入框\n- **移除驾驶舱卡片展开折叠按钮** - ui: 移除驾驶舱卡片展开折叠按钮\n- **调整驾驶舱渠道列表高度** - ui: 调整驾驶舱渠道列表高度\n- **修复 customHeaders 覆盖 Copilot token 的安全漏洞** - copilot: 修复 customHeaders 覆盖 Copilot token 的安全漏洞\n- **修复响应协议转换与认证头泄露** - copilot: 修复响应协议转换与认证头泄露\n- **修复 ConversationChannelSequence Vuetify CSS 颜色变量 alpha 写法** - ui: 修复 ConversationChannelSequence Vuetify CSS 颜色变量 alpha 写法\n- **Compshare 月度额度耗尽自动拉黑** - `extractErrorInfo`/`extractErrorCode` 兼容大写字段名 (`RetCode`/`Message`) 和数字类型错误码;`isInsufficientBalanceMessage` 增加 `monthly limit exceeded` 等精确短语,使 429 + 月度额度耗尽错误能正确触发 key 拉黑\n- **收敛会话卡片高度避免撑爆页面** - cockpit: 收敛会话卡片高度避免撑爆页面\n- **保留原始元数据识别子代理** - conversation: 保留原始元数据识别子代理\n- **增强调试日志定位响应协议** - messages: 增强调试日志定位响应协议\n- **补齐驾驶舱看板文案** - i18n: 补齐驾驶舱看板文案\n- **注册驾驶舱图标** - web: 注册驾驶舱图标\n- **恢复驾驶舱顶部入口** - web: 恢复驾驶舱顶部入口\n\n### 优化\n\n- **优化控制台图表长周期采样** - desktop: 优化控制台图表长周期采样\n- **优化历史图表长周期采样** - frontend: 优化历史图表长周期采样\n\n### 重构\n\n- **清理会话反馈功能** - 清理会话反馈功能\n- **提取 ConversationChannelSequence 组件,消除主对话/子代理渠道列表重复** - ui: 提取 ConversationChannelSequence 组件,消除主对话/子代理渠道列表重复\n- **子代理渠道交互改为行列表,与主对话保持一致** - cockpit: 子代理渠道交互改为行列表,与主对话保持一致\n- **同步驾驶舱 BoardItem 模型到桌面端** - desktop: 同步驾驶舱 BoardItem 模型到桌面端\n- **Dashboard 适配 ConversationBoardItem 结构** - web: Dashboard 适配 ConversationBoardItem 结构\n- **重构驾驶舱子代理分组与聚合逻辑** - web: 重构驾驶舱子代理分组与聚合逻辑\n- **抽取驾驶舱列分组工具** - web: 抽取驾驶舱列分组工具\n\n### 测试\n\n- **补齐 vLLM reasoning 兼容覆盖** - providers: 补齐 vLLM reasoning 兼容覆盖\n- **强化 Gemini 图片估算测试并提速** - utils: 强化 Gemini 图片估算测试并提速\n- **补齐 copilot 包 httptest 覆盖** - copilot: 补齐 copilot 包 httptest 覆盖\n\n### 文档\n\n- **记录 Claude Code v2.1.193 变更评估** - todo: 记录 Claude Code v2.1.193 变更评估\n- **完善兼容选项提示** - frontend: 完善兼容选项提示\n- **记录 Codex 上游变更评估** - todo: 记录 Codex 上游变更评估\n- **更新 CHANGELOG 记录 Compshare 月度额度拉黑修复** - 更新 CHANGELOG 记录 Compshare 月度额度拉黑修复\n\n### 其他\n\n- **升级前端依赖** - deps: 升级前端依赖\n- **更新 Go 与 Wails 相关依赖** - deps: 更新 Go 与 Wails 相关依赖\n\n## [v2.9.17] - 2026-06-23\n\n### 新增\n\n- **桌面端会话父子关系导航** - 展示会话父子关系导航,便于在关联会话间跳转\n- **关联子代理会话记录** - 支持记录与展示子代理关联会话\n- **桌面端会话驾驶舱任务看板** - 改造会话驾驶舱任务看板,增强子代理任务概览\n- **桌面端独立驾驶舱入口** - 添加独立驾驶舱入口,提升访问效率\n- **会话反馈记录** - 支持记录会话反馈信息\n- **驾驶舱子代理看板改进** - 改进 cockpit subagent board 展示能力\n\n### 修复\n\n- **统计图表长跨度采样与展示** - 将 Web 与桌面端全局/渠道统计图默认采样点控制在 200 以内,并修复渠道级图表在仅最近几天有数据时选择 1 年导致曲线被全年空白压缩的问题\n- **桌面端能力测试渠道复制反馈** - 改进能力测试渠道复制反馈体验\n\n### 重构\n\n- **桌面端侧边栏导航分组拆分** - 拆分侧边栏导航分组,优化导航结构\n- **桌面端驾驶舱入口分离** - 将驾驶舱入口从管理控制台分离\n\n### 文档\n\n- **已完成 TODO 清理** - 移除已完成 TODO 项\n\n### 其他\n\n- **统计图表采样展示修复合并** - 合并统计图表采样展示修复\n- **桌面端开发模式启动顺序** - 调整开发模式前端启动顺序\n- **桌面端开发壳构建任务** - 添加开发壳构建任务\n\n## [v2.9.16] - 2026-06-23\n\n### 修复\n\n- **Docker 共享前端工具嵌入** - Docker 构建包含共享前端工具文件,避免容器构建缺失共享工具\n\n## [v2.9.15] - 2026-06-23\n\n### 新增\n\n- **Kimi K2.7 能力别名** - 添加 Kimi K2.7 模型能力别名,支持默认模型能力识别\n- **Qwen3.7 模型阶梯定价** - 添加 Qwen3.7 模型阶梯定价配置\n- **Codex tool_search_output 合并** - Responses 支持合并 Codex `tool_search_output` 工具输出\n- **桌面端模型下拉自动滚动定位** - 添加模型下拉列表自动滚动定位功能\n- **版本升级构建监控** - version-bump 技能新增构建进度监控流程\n\n### 修复\n\n- **Codex Responses 默认模型** - 更新 Codex Responses 共享预设默认模型\n- **桌面端渠道中心独立滚动** - 修复渠道中心滚动区域交互\n- **能力检测重试一致性** - 修复 capability retry consistency\n- **桌面端能力检测提示层裁剪** - 修复能力检测模型提示层被裁剪的问题\n- **MiMo Codex 兼容预设** - 开启 MiMo Codex 兼容预设\n- **桌面端源模型预设过滤** - 过滤已配置的源模型预设选项\n- **桌面端目标模型下拉滚动定位** - 修复目标模型下拉滚动定位\n- **桌面端目标模型完全匹配显示** - 修复目标模型完全匹配时不显示窗口的问题\n- **桌面端源模型选择展开** - 修复源模型选择后再次点击不展开列表的问题\n- **桌面端源模型下拉显示** - 修复源模型下拉列表不显示的问题\n- **桌面端模型重定向空胶囊** - 移除模型重定向源模型输入框的空胶囊元素\n\n### 重构\n\n- **渠道协议预设抽取** - 抽取渠道协议预设\n- **Codex Responses 共享预设抽取** - 抽取 Codex Responses 共享预设\n- **前端模型优先级排序共享** - 共享模型优先级排序逻辑\n\n### 测试\n\n- **用户额度不足黑名单用例** - 补充用户额度不足黑名单用例\n\n## [v2.9.14] - 2026-06-22\n\n### 新增\n\n- **Token 统计时间范围扩展到 1 年** - 新增 90 天、半年、1 年、今年等长时间范围选项\n - 后端数据保留期默认值:30 天 → 366 天,上限:90 天 → 366 天(闰年全年覆盖)\n - API 时间范围上限:30 天 → 366 天\n - 新增时间格式支持:`90d`、`180d`、`365d`、`thisyear`\n - 聚合间隔优化:长时间范围使用 8-12 小时间隔,控制数据点数量\n - Web 端和桌面端所有图表组件同步更新\n - 统一两端时间选项文案:桌面端 `7d`/`30d` → `1周`/`1月`\n - 新增中文、英文、印尼文三语言国际化文案\n- **Unity2.ai 赞助商集成** - 新增 Unity2.ai 赞助商渠道预设与国际化文案支持\n- **Unity2.ai 国际化文案** - 添加 Unity2.ai 渠道预设翻译\n- **自动赞助商集成技能** - 新增 add-sponsor skill,支持自动化赞助商集成流程\n- **对齐 Web 与桌面端渠道菜单行为** - 统一两端渠道操作菜单交互逻辑\n\n### 修复\n\n- **Codex Responses 共享预设默认模型** - 将 Kimi 默认模型同步为 `kimi-k2.7` 并补齐能力注册,GLM 默认模型升级为 `glm-5.2`,避免 Web/desktop 一键预设回退到旧模型\n- **Unity2.ai 和 RunAPI 响应剥离图片生成工具** - 为 Unity2.ai 和 RunAPI 响应剥离 image generation tool\n- **Unity2.ai 使用原生 responses 协议** - Unity2.ai 改用原生 responses 协议\n- **Unity2.ai 添加 anthropic plan 支持 Messages 目标** - 为 Unity2.ai 添加 anthropic plan 以支持 Messages target\n- **Unity2.ai targets 顺序修正** - 修正 Unity2.ai targets 顺序为标准 Messages → Responses → Chat\n- **恢复原生 tool_search 调用** - 恢复 Codex 原生 tool_search calls\n- **为 tool_search stream 发送 custom_tool_call_input.done** - 确保 tool_search 流式响应完整性\n- **保留 tool_search 历史参数** - 保持 Codex tool_search history arguments\n- **恢复 tool_search 自定义调用语义** - 恢复 tool_search custom call semantics\n- **统一两端高级选项选择框宽度与布局** - 统一 Web 和桌面端渠道编辑器高级选项布局\n- **tool_search 保留原始参数 schema 映射为 function** - converters 中 tool_search 保留原始参数 schema 映射\n- **统一渠道菜单文案与 Web 端** - 桌面端渠道菜单文案对齐 Web 端\n- **保留搜索结果渠道置顶置底操作** - 桌面端搜索结果中保留渠道排序操作\n\n### 重构\n\n- **预设配置辅助函数抽取** - 使用 helper functions 减少 preset 配置重复代码\n\n### 测试\n\n- **Codex chat 子代理工具调用测试覆盖** - 为 chat sub-agent tool calls 添加测试覆盖\n\n### 文档\n\n- **更新 add-sponsor skill 文档** - 更新 add-sponsor skill 最新使用模式\n\n### 其他\n\n- **移除 add-sponsor.sh 脚本** - 删除旧的 add-sponsor shell 脚本\n\n## [v2.9.13] - 2026-06-21\n\n### 新增\n\n- **子代理感知对话路由与亲和隔离** - 新增 subagent-aware conversation routing 与 affinity isolation,减少不同子代理会话间的路由串扰\n- **额外代理访问密钥** - 支持额外代理访问密钥配置,扩展代理入口鉴权能力\n- **内置模型预设匹配改用正则表达式** - builtin model registry 的 patterns 从简单通配符(`claude-opus-4-7*`)改为显式正则,精确控制前后边界,避免 `gpt-5.4-mini` 被 `gpt-5.4` 误吃、`glm-5.1` 被 `glm-5` 误吃等歧义;支持厂商前缀(`bedrock/claude-opus-4-7`、`xxx-claude-opus-4-7`)和日期版本号后缀(`-20260101`),同时拒绝 `claude-haiku-4-5-with-claude-opus-4-7-fallback` 这类异常组合名\n - 一个 entry 支持多个 pattern(如 `kimi-for-coding` 别名回退到 `kimi-k2.7-code`),未来 Kimi 升级到 K2.8 只需把别名移到新 entry\n - 前端 `channelPayload.ts`、桌面端 `channel-payload.ts` 匹配函数改用 `new RegExp(pattern, 'i')`\n - 后端 `model_registry.go` 新增 `builtinPatternCache` 预编译缓存,RE2 兼容剥离 `(?=$|@)` lookahead 后手动检查边界;`resolvePatternValueFold` 先正则再通配符\n - generator 加构建时正则校验\n - **用户路由级 supportedModels 配置不受影响**,继续用通配符语义\n- **手动暂停与自动熔断区分显示** - UI 区分手动暂停与自动熔断状态展示\n- **images 渠道纳入模型聚合** - 模型列表聚合纳入 images 渠道,并支持多渠道并行收集聚合\n- **Key 权重与模型选择** - Key 池新增 Weight/Models 选择能力,支持 metadata restore 测试并在前端加载 `apiKeyConfigs`\n- **按 Key 凭证池与限速** - 新增 per-key credential pool,支持按 Key 作用域进行限速\n\n### 修复\n\n- **调度器文件拆分冲突修复** - 解决 worktree 合并后的 scheduler file-split 冲突\n- **驾驶舱交互与 Key 管理** - 修复桌面端驾驶舱交互与 Key 管理若干问题\n- **驾驶舱暂停/熔断状态徽标区分** - 区分暂停与熔断状态 badge 并统一文案\n- **驾驶舱渠道选择交互** - 修复驾驶舱渠道选择交互问题\n- **渠道统计图表交互对齐** - 对齐渠道统计图表交互逻辑\n- **重复 Key 提示** - 添加重复 Key 时给出明确提示\n- **桌面 dashboard 控制台交互** - 修复桌面端 dashboard console interactions\n- **Responses 转换保持 Codex 工具兼容** - preserve Codex tool compatibility in responses conversion\n- **Dashboard 渠道编辑弹窗** - 修复桌面端 dashboard 渠道编辑弹窗问题\n- **限速高水位调度优化** - 优化调度器限速高水位策略\n- **渠道统计包含黑名单 Key** - channel stats 纳入 blacklisted keys\n- **渠道编辑对话框高度** - 优化渠道编辑对话框高度\n- **黑名单快照与配置监听** - 加固 blacklist snapshot 与 config watcher\n- **Code review 反馈问题修复** - 修复 code review 确认的 11 个 keypool/ratelimit/config 问题\n- **ChannelView 返回 apiKeyConfigs** - BuildChannelView 返回 `apiKeyConfigs`\n- **模型映射同步后再能力测试** - 前端 capability test 前同步 model mapping\n\n### 重构\n\n- **配置更新逻辑抽取** - 抽取 `applyAPIKeyConfigUpdate` 并改进 watcher 防抖策略\n- **前端活动可视化与单测** - 改进 activity visualization 并补充 unit tests\n- **ChannelOrchestration 样式拆分** - 从 `ChannelOrchestration.vue` 抽取样式\n- **EditChannelModal 样式与优先级逻辑拆分** - 从 `EditChannelModal.vue` 抽取样式和 model-priority 逻辑\n- **App 样式与能力测试组合式函数拆分** - 从 `App.vue` 抽取样式和 capability-test composable\n- **ChannelOrchestration 活动组合式函数抽取** - 抽取 `useChannelActivity` composable\n- **Responses handler 拆分** - 拆分 `handler.go`(2160 行 → 5 个文件)\n- **Chat handler 拆分** - 拆分 `handler.go`(2305 行 → 5 个文件)\n- **Scheduler 测试拆分** - 拆分 `channel_scheduler_test.go`(1650 行 → 3 个文件)\n- **Scheduler 实现拆分** - 拆分 `channel_scheduler.go`(1535 行 → 4 个文件)\n- **通用 stream helper 拆分** - 拆分 `handlers/common/stream.go`(2799 行 → 4 个文件)\n- **Metrics 实现拆分** - 拆分 `channel_metrics.go`(3731 行 → 5 个文件)\n\n### 文档\n\n- **待办事项更新** - 更新 TODO 文档\n\n### 其他\n\n- **额外代理访问密钥合并** - 合并额外代理访问密钥支持\n- **渠道编辑对话框高度修复合并** - 合并渠道编辑对话框高度修复\n- **黑名单 Key 统计修复合并** - 合并 blacklisted key stats fix\n- **Go 格式化** - 执行 go fmt 格式化\n\n## [v2.9.12] - 2026-06-19\n\n### 修复\n\n- **上下文窗口按输入 token 过滤** - 调度器按输入 token 过滤 context windows,避免选择不满足输入上下文要求的渠道\n- **Docker 前端嵌入构建稳健性改进** - 改进 Docker frontend embed build robustness,提升前端产物嵌入构建稳定性\n\n## [v2.9.11] - 2026-06-19\n\n### 修复\n\n- **桌面控制台 fallback 文案本地化** - 桌面端控制台 fallback labels 使用多语言文案,避免未翻译标签展示\n\n## [v2.9.10] - 2026-06-19\n\n### 新增\n\n- **上游认证头覆盖配置** - 后端新增 upstream auth header override,支持按渠道覆盖上游认证头\n- **渠道认证头设置入口** - 前端暴露 channel auth header 设置,支持管理界面配置认证头覆盖\n- **OpenCode Go 预设 x-api-key** - 桌面端 OpenCode Go 预设自动设置 x-api-key 认证头\n\n### 修复\n\n- **模型能力标签简化** - 简化前端 model capability labels,改善模型能力展示文案\n\n### 其他\n\n- **SignPath artifact 安全暂存** - SignPath artifacts 使用安全名称暂存,避免签名流程中的产物名称冲突\n\n## [v2.9.9] - 2026-06-19\n\n### 其他\n\n- **SignPath 签名步骤唯一 artifact 名称** - 为 SignPath 签名步骤添加唯一 artifact 名称,避免多平台并行构建时名称冲突\n\n## [v2.9.8] - 2026-06-19\n\n### 新增\n\n- **OpenCode Zen/Go 渠道预设更新至 glm-5.2** - 桌面端 OpenCode Zen/Go 预设的 Claude 与 Codex 目标模型映射更新为 glm-5.2 / deepseek-v4-flash,并补齐对应 reasoning 映射\n\n### 修复\n\n- **视觉回退模型上下文能力自动添加** - 前端和桌面端在上下文能力自动添加时纳入视觉回退模型,并按大小写不敏感方式去重,避免回退模型缺失能力配置行\n- **前端 lint 存量问题清理** - 修复 Web 前端 ESLint 存量错误与警告,清理编辑渠道弹窗拆分后的未使用旧逻辑并补齐相关类型声明\n- **Responses SSE 扫描缓冲区上限提升** - 将 Responses 流式 SSE 单行扫描上限提升至 32MB,避免图片/产物等大事件被截断导致流式失败\n\n### 其他\n\n- **upstream-check seen 标签数量限制** - 限制 upstream-check skill 记录的 seen upstream tags 数量,避免状态无限增长\n\n## [v2.9.7] - 2026-06-19\n\n### 新增\n\n- **模型响应模态信息增强** - 增强 models 接口响应中的 modalities 信息\n\n### 修复\n\n- **前端模型目标建议修复** - 修复前端模型 target suggestions 展示与匹配问题\n- **发布暂存目录可见性修复** - 使用可见 release staging directory,修复发布打包暂存路径问题\n\n## [v2.9.6] - 2026-06-19\n\n### 修复\n\n- **移除 agent profile 窗口硬最低限制** - 移除桌面端 agent profile 窗口硬最低限制,新增上下文估算日志\n- **简化模型能力定价编辑器** - 简化前端 model capability pricing editor 交互\n- **修复 capability test 模型映射复用** - 修复 capability test 模型映射复用问题\n- **优化模型能力行布局** - 优化桌面端 model capability row layout\n- **改善 capability test 状态处理** - 改善桌面端 capability test status handling\n- **同步桌面端模型能力配置** - 同步桌面端 model capabilities 配置\n\n## [v2.9.5] - 2026-06-19\n\n### 新增\n\n- **桌面端模型能力编辑器改进** - 改进桌面端 model capability editor 交互与布局\n- **前端模型能力编辑器改进** - 改进前端 model capability editor 交互与布局\n- **DeepSeek V4 能力更新** - 更新 model registry 中 DeepSeek V4 模型能力信息\n\n### 其他\n\n- **SignPath 测试签名校验放宽** - 放宽 SignPath test signature validation,提升测试环境签名校验兼容性\n\n## [v2.9.4] - 2026-06-19\n\n### 新增\n\n- **上游模型能力注册表** - 新增 upstream model capability registry,用于集中维护上游模型能力信息\n- **上下文感知渠道路由** - 新增 context-aware channel routing,根据请求上下文与模型能力选择更合适的渠道\n\n### 修复\n\n- **桌面端目标模型选项来源修复** - 桌面端使用已拉取的目标模型选项,避免候选模型与上游能力不一致\n- **桌面端渠道能力测试流程对齐** - 对齐桌面端渠道 capability test 流程,提升测试结果一致性\n- **桌面端渠道重排序类型处理修复** - 修复桌面端 channel reorder 的类型处理问题\n\n### 其他\n\n- **SignPath 签名集成** - CI 集成 SignPath signing,完善发布签名流程\n\n## [v2.9.3] - 2026-06-18\n\n### 新增\n\n- **讯飞星辰渠道预设** - 新增 ProviderXFyun 预设,Anthropic + OpenAI 双入口,支持 Messages/Chat/Responses 三目标,含快速输入 URL 识别与中英文 locale\n- **Codex SQLite 迁移修复 visibility 字段** - migrateCodexStateDB 动态检测 threads 列,自动回填 preview/has_user_event/thread_source 等隐藏线程可见性字段\n- **中转站错误码 failover 识别增强** - 增强中转站错误码识别与黑名单判定,提升故障转移覆盖\n- **渠道运行态冷却与上游账号池降级** - 调度器新增渠道运行态冷却与上游账号池不可用降级策略\n- **Compshare glm-5.2 预设与推理映射同步** - 桌面端 ChannelEditDialog 同步 Compshare glm-5.2 预设与 reasoning 映射\n- **火山方舟 Compshare 预设更新至 glm-5.2** - 更新桌面端火山方舟 Compshare 渠道预设模型\n\n### 修复\n\n- **渠道日志按稳定渠道名过滤共享 metricsKey** - GetChannelLogs 对共享 metricsKey 优先按创建时渠道名筛选,旧日志回退 channelIndex,避免删除/重排后日志串台\n- **Codex SQLite visibility 回填来源保护** - 迁移 threads 时根据 source 列跳过 exec 等后台线程,避免误标记为用户会话\n- **trace 亲和性遵守 SupportedModels** - 新增测试验证亲和渠道不支持当前模型时正确跳过并回退优先级选择\n- **共享 metricsKey 渠道日志按渠道名归属** - 修复共享 metricsKey 场景下渠道日志归属到稳定渠道名\n- **共享 metricsKey 渠道日志按 channelIndex 过滤** - 修复渠道日志在共享 metricsKey 时按 channelIndex 过滤\n- **短流 EOF 重试策略** - 在 message_stop 前遇到短流 EOF 时进行重试\n- **新建渠道使用生成名称提交** - 修复桌面端新建渠道提交时未使用生成名称的问题\n\n### 文档\n\n- **CHANGELOG 维护更新** - 更新 CHANGELOG,补充讯飞星辰、Codex 迁移修复、日志过滤与调度测试记录\n\n## [v2.9.2] - 2026-06-18\n\n### 新增\n\n- **火山方舟预设模型与推理映射更新** - 更新桌面端火山方舟渠道预设模型与 reasoning 映射\n- **渠道卡片缓存命中率展示** - 桌面端渠道卡片新增缓存命中率显示\n- **渠道添加偏好卡片式交互** - 渠道添加流程改为卡片式交互,提升偏好选择体验\n- **渠道日志 reasoning effort 展示同步** - 桌面端渠道日志同步展示 reasoning effort 信息\n- **渠道支持选择队列末尾并记住偏好** - 前端新增渠道支持放到队列末尾,并记住用户偏好\n- **CompactionV2 本地 compact 与 SSE 转换** - Responses 实现 CompactionV2 本地压缩并支持 SSE 转换\n- **Passthrough converter ResponsesItem 字段映射补全** - passthrough converter 补齐 ResponsesItem 全量字段映射\n- **ResponsesItem EncryptedContent 字段** - 类型定义新增 EncryptedContent 字段以支持 compaction\n- **视觉回退 reasoning effort 与 output_config.effort 同步** - 视觉回退模型支持 reasoning effort,并同步 output_config.effort\n- **渠道日志 reasoning effort chips 展示** - 前端渠道日志弹窗新增 reasoning effort chips\n- **视觉回退 reasoning effort 选择器与多语言文案** - 前端新增视觉回退 reasoning effort selector 和 locale keys\n- **Claude 渠道 reasoning mapping 支持** - 前端扩展 reasoning mapping 支持到 claude channel type\n- **Claude thinking effort 渠道映射注入** - providers 根据渠道 reasoningMapping 注入 Claude thinking effort\n- **ChannelLog reasoning effort 字段** - metrics 新增 originalReasoningEffort 与 actualReasoningEffort\n- **渠道日志 reasoning effort 提取 helpers** - handlers 新增 reasoning effort 提取辅助函数\n\n### 修复\n\n- **火山方舟 Responses 视觉回退补齐** - 桌面端补齐火山方舟 Responses 视觉回退配置\n- **orchestration promotion 标签文案优化** - 优化桌面端 orchestration promotion 标签文案\n- **legacy reasoning params 转换顺序修复** - providers 在重写为 block format 前先转换 legacy reasoning params\n- **模型映射 noVision 同步修复** - 按目标模型改进 noVision 在 model mappings 间的同步逻辑\n- **模型映射 noVision flag 同步修复** - 修复 noVision flag 按 target 在 model mappings 间同步\n- **Add/Edit Channel Modal 缺失导入修复** - 补充 AddChannel/EditChannel modals 中 normalizeSelectableString 导入\n- **Chat reasoning effort 映射模型来源修复** - Chat 使用实际请求体 model 进行 reasoning effort mapping\n- **重复 reasoning effort 标签合并** - 合并 UI 中重复的 reasoning effort 标签显示\n\n### 重构\n\n- **EditChannelModal 请求 URL 构建统一** - 统一 EditChannelModal 使用 buildExpectedRequestUrls\n- **AddChannelModal 快速添加职责收敛** - 移除 AddChannelModal 编辑模式,仅保留 quick-add\n- **reasoning effort 校验逻辑统一** - config 使用 isValidReasoningEffort 统一 reasoning effort validation\n\n## [v2.9.0] - 2026-06-17\n\n### 新增\n\n- **运行时超时热更新与 responseHeaderTimeout 渠道字段** - 新增 `responseHeaderTimeoutMs` 渠道字段,支持全局运行时超时热更新,httpclient 支持显式 responseHeaderTimeout\n- **熔断器/调校台新增超时滑块** - 桌面端和前端熔断器、调校台新增 `requestTimeoutMs` 与 `responseHeaderTimeoutMs` 运行时滑块\n- **CompactModel 配置支持** - Responses 渠道新增 `compactModel` 配置字段,支持压缩模型自动选择\n- **内联模型映射编辑器** - 所有渠道类型支持内联编辑模型映射,无需弹窗\n- **主动限速滑动窗口算法** - 令牌桶改为滑动窗口算法,支持可配置的窗口时长,自动学习上游限速默认开启\n- **b64_json 图片响应自动转换** - 支持 `b64_json` 请求的 URL 响应自动转换\n- **向下游下发可读的空响应错误文案** - 流式响应场景向客户端下发可读错误信息而非空流\n- **桌面端 App 级 polling 调度层** - ConversationDashboard 轮询迁移至 App 级调度,支持 window visibility 事件控制\n- **桌面端图表功能对齐 Web 端** - 桌面端控制面板使用统计图表与 Web 端完全对齐\n- **渠道活动图表背景** - ChannelCard 新增活动图表背景可视化\n- **渠道快速创建卡片上游类型品牌色与图标** - 快速创建渠道卡片的上游类型展示品牌色和对应图标\n- **渠道编辑器输出冗长度配置** - 渠道编辑器新增输出冗长度配置选项\n- **驾驶舱空闲自动恢复时间持久化配置** - 支持通过环境变量和前端选择器持久化配置 override 有效期\n- **Kimi Coding Plan 模型重定向与智能排序** - Kimi 渠道实现 Coding Plan 模型重定向与智能模型排序\n- **桌面端模型重定向区域 premium UI 重构** - 目标模型输入改为自定义下拉菜单,支持下拉建议\n- **快速模式与历史图片轮次限制配置** - Web 端补全快速模式与历史图片轮次限制配置\n- **流式断流超时预设快捷按钮** - 桌面端新增流式断流超时预设快捷按钮\n- **Esc 快捷键提示** - Web 和桌面端为对话框关闭按钮添加 Esc 快捷键提示\n- **Compatibility 分组高级开关补全** - Web 端和桌面端补全 Compatibility 分组所有缺失的高级开关\n- **桌面端 i18n 全面国际化** - 迁移到 vue-i18n + 外部 JSON 语言文件,桌面端渠道编辑、控制台、图表等全面 i18n 化\n- **文档: Geo SEO, llms.txt, README 桌面安装说明** - 新增 Geo SEO、llms.txt,改进贡献指南和 README 桌面安装说明\n\n### 修复\n\n- **临时限流不触发 Key 黑名单** - 临时限流场景不再误将 Key 加入黑名单\n- **REQUEST_TIMEOUT 默认值从 300s 调整为 120s** - 调整默认请求超时为更合理的 120 秒\n- **保留 web_search 工具以兼容 Codex v0.139.0+** - Responses 场景保留 web_search 工具兼容新版 Codex\n- **修复 UpstreamUpdate JSON 标签拼写错误** - 修正配置结构体 JSON tag 拼写\n- **图表系列修复** - 修复 KeyTrendChart yaxis 绑定、series 复用、forceNiceScale、失败率柱透明度等多项图表问题\n- **模型映射编辑修复** - 修复 modelMapping 对象值保存失败、combobox 值规范化、目标模型输入框抖动等问题\n- **限速配置修复** - 修复 0 值显示为空、窗口时长清空后无法保存、rateLimitWindowMinutes 字段遗漏等\n- **桌面端控制台组件修复** - 修复组件缺失导入、滚动导航、Esc 键 window 全局监听等\n- **i18n 修复** - 修复英文模式下统计图表中文、id.json 时长单位错误、useI18n setup 上下文时序等\n- **resumeChannel 后自动调用 setStatus('active')** - 恢复渠道后正确更新状态\n- **REQUEST_TIMEOUT/RESPONSE_HEADER_TIMEOUT 降级为启动兜底** - 超时配置运行时以调校台为准,环境变量仅作启动兜底\n\n### 重构\n\n- **渠道编辑器全面重构** - 左侧导航+右侧面板布局,拆分快速添加与编辑弹窗,统一内容顺序对齐 WebUI\n- **高级选项分组重构** - 创建 Runtime 运行期策略分组,统一布局结构,主动限速拆分为独立 RateLimitGroup\n- **模型映射区域深度重构** - 行内可编辑、视觉回退模型行内开关驱动,优化布局与样式\n- **快速添加面板重构** - 紧凑两栏布局、卡片式分块布局、上游类型选择器标题行后缀推荐项\n- **渠道卡片状态展示重构** - 统一交互逻辑、展开按钮与统计图表折叠优化\n- **熔断器/调校台滑块设计重构** - 参考 Web 端样式,增强可见性和对比度\n- **桌面端渠道编辑创建流程重构** - 统一使用 t 多语言函数,对齐内容顺序与 WebUI\n- **vue-i18n 迁移** - 从自定义 i18n 迁移到 vue-i18n + 外部 JSON 语言文件,统一两端 key 命名空间\n- **ChannelManager/ChannelTab 惰性加载** - 非活跃页跳过刷新,提升性能\n- **CircuitBreakerDialog 布局重构** - header/footer 固定,body 独立滚动\n\n### 文档\n\n- **新增 RESPONSE_HEADER_TIMEOUT 与运行时超时配置说明**\n- **更新 compactModel 说明,明确 Responses 渠道 failover 机制**\n\n## [v2.8.28] - 2026-06-11\n\n### 新增\n\n- **桌面端管理面板集成用量统计图表 (#199)** - 管理面板新增用量统计图表展示\n- **补齐渠道中心所有 provider 到 Agent 直连设置** - 完善 Agent 直连配置,覆盖所有渠道中心 provider\n- **驾驶舱 Override 有效期选择器补齐** - 驾驶舱 Override 配置界面新增有效期选择器\n- **upstream-check 新增功能与体验变更报告输出步骤** - 上游版本检查 skill 新增变更报告输出\n\n### 修复\n\n- **完善驾驶舱 i18n 并移除重复的系统状态显示** - 优化驾驶舱国际化文案,移除重复状态展示\n- **补全 tooltip content-class 并加大快捷键提示间距** - 修复 tooltip 样式类缺失,优化快捷键提示视觉间距\n- **补全 stream.go 中 message_delta 的 stop_details 字段** - 流式响应 handlers 补充 stop_details 字段处理\n- **添加 stop_details 字段到 message_delta** - providers 层新增 stop_details 字段支持\n- **过滤 compaction 流式响应中的 reasoning items (#179)** - Responses compaction 场景过滤推理内容,避免重复输出\n- **优化弹窗快捷键提示体验** - 改进弹窗快捷键提示交互\n- **Override 有效期标签改为用户易懂的「空闲自动恢复」** - 重写 Override 配置文案,提升用户理解\n- **有效期选择器默认选中 30min + 补充长时段选项** - 优化有效期默认值与选项范围\n- **修复磁贴图标透明背景与黄色光圈,补齐 Windows 磁贴资产** - 修复桌面端 Windows 图标透明度问题,补全磁贴资源\n- **千帆 Coding Plan 渠道补充模型映射与特性配置** - 完善千帆编码计划渠道模型映射\n- **火山方舟 Coding Plan 渠道补充模型映射与特性配置 (#204)** - 完善火山方舟编码计划渠道模型映射\n- **替换已归档官方镜像为社区维护 fork,修复 Docker API 版本兼容性问题** - Watchtower 迁移至社区维护镜像,修复 API 兼容性\n\n### 优化\n\n- **前端构建增加双层缓存,避免重复编译** - 前端构建流程新增缓存机制,加速构建\n\n### 改进\n\n- **驾驶舱 Override 有效期可配置** - 支持环境变量 `OVERRIDE_TTL_MINUTES`(默认 30 分钟)和前端下拉选择器(15min/30min/1h/2h/永不恢复)自定义 override 有效期\n- **Override Idle 续期** - 对话活跃时自动续期 override TTL,仅在闲置超过设定时间后才恢复默认调度\n- **Override 永不恢复选项** - 支持设置 override 永不过期,手动恢复前不会自动过期\n\n### 文档\n\n- **注释说明极易云 originrouter 刻意不纳入 Agent 直连** - 渠道预设注释补充设计决策说明\n- **新增 TODO #199 桌面端用量显示与 #179 compaction v2 think 拆分问题** - 更新待办事项\n- **完成 TODO #1 OpenRouter 免费路由工具调用排查,确认为上游限制** - 标记已完成任务\n- **清理已完成任务并添加三个新 TODO** - 维护任务列表\n\n### 变更文件\n\n- `backend-go/internal/conversation/override.go` - IsPerpetual、per-call duration、RefreshOverrideForUser、SetDefaultTTL\n- `backend-go/internal/conversation/override_test.go` - 新增 8 个测试用例\n- `backend-go/internal/config/env.go` - 新增 OVERRIDE_TTL_MINUTES 环境变量\n- `backend-go/main.go` - 读取环境变量替换硬编码 30 分钟\n- `backend-go/internal/handlers/conversation_handler.go` - API body 新增 duration 参数,响应新增 isPerpetual\n- `backend-go/internal/scheduler/channel_scheduler.go` - SelectChannel 成功消费 override 时调用 RefreshOverrideForUser\n- `frontend/src/components/ConversationDashboard.vue` - 新增 override 持续时间下拉选择器\n- `frontend/src/components/ConversationCard.vue` - 永久 override 展示逻辑\n- `frontend/src/services/api-types.ts` - SequenceOverrideInfo 新增 isPerpetual\n- `frontend/src/services/api.ts` - setConversationOverride 新增 duration 参数\n- `frontend/src/i18n/messages-{zh-cn,en,id}.ts` - 新增 4 个 i18n key\n\n## [v2.8.27] - 2026-06-10\n\n### 新增\n\n- **桌面端 MiniMax M3 渠道预设** - 新增 MiniMax M3 channel presets\n- **桌面端讯飞星辰 MAAS 直连支持** - 添加讯飞星辰 MAAS 桌面端 Agent 直连支持\n- **弹窗快捷键提示与确认交互** - 所有弹窗添加 ESC 取消与 Cmd/Ctrl+Enter 确认快捷键提示,提升操作效率\n\n### 修复\n\n- **Responses serviceType claude 路径认证修复** - serviceType claude 路径正确使用 x-api-key 认证头\n- **桌面端 OpenCode 讯飞星辰直连精确移除** - 仅移除 OpenCode 的讯飞星辰直连,保留 Claude/Codex 路径\n- **Fable reasoning 迁移补齐 model mapping** - migrate fable reasoning mapping with model mapping\n- **REWRITE_RESPONSE_MODEL 支持 Responses API** - Responses API 场景正确支持 REWRITE_RESPONSE_MODEL 重写\n\n### 重构\n\n- **统一 codex responses 预设 source key** - 统一 codex responses 预设 source key 为 codex/gpt/mini\n\n### 其他\n\n- **Responses provider 认证头 serviceType 分支测试** - 添加 Responses provider 认证头 serviceType 分支测试用例\n\n## [v2.8.26] - 2026-06-10\n\n### 新增\n\n- **Claude Fable 模型支持** - 新增 Claude Fable 系列模型映射与兼容处理,升级时自动补齐 Fable 模型映射配置\n- **渠道级主动限速与上游 rate limit header 自动追踪 (#190)** - 新增渠道级令牌桶限速、并发控制与 cooldown 机制,自动解析上游 `X-RateLimit-*` / `Retry-After` 响应头并动态调整限速参数,MiMo 默认 RPM=80\n- **历史图片轮次限制与占位符替换** - 新增渠道级历史图片轮次限制配置(`historicalImageTurnLimit`),超过指定轮次的历史对话图片自动替换为 `[Image]` 占位符,避免不必要的 vision 回退模型切换。覆盖 Claude Messages、OpenAI Chat、Responses API、Gemini 四种协议格式,Web/桌面渠道编辑 UI 支持按渠道配置\n- **渠道体验增强与 Messages 渠道级 CCH 开关** - Web/桌面渠道列表新增 15 分钟窗口”缓存写偏高”提醒 badge;桌面端 RunAPI messages 预设默认关闭 `normalizeMetadataUserId`;`stripBillingHeader` 从全局设置下沉为 messages 渠道级开关,默认关闭,并同步补齐 Web/桌面渠道编辑表单与老配置迁移逻辑\n- **桌面端 OpenRouter 与 ModelScope 渠道预设** - 新增 OpenRouter、ModelScope 渠道中心预设,并将 Kimi Code Plan 合并入 Kimi 预设作为编码计划条目,添加到 Claude/Codex/OpenCode 直连配置列表\n- **桌面端渠道预设扩充** - 新增 RunAPI 赞助商预设、Tencent TokenHub 预设与国内 coding-plan 网关预设,统一 RunAPI 与 Token Plan 文案,优化 RunAPI 渠道预设排序\n- **桌面端管理面板 Fuzzy 开关与熔断器配置** - 管理面板工具栏新增 Fuzzy 模式开关和熔断器配置按钮,熔断器配置预设值、slider 范围与 WebUI 同步对齐\n- **桌面端 Codex/Responses 分层排障诊断** - 在桌面 Agent 页 Codex 卡片新增分层排障,按顺序检查”Codex 配置一致性 → Responses 渠道可用性 → 最近失败请求”,帮助定位 #156 类”本地网关可达但请求失败”的问题。后端扩展 `AgentConfigStatus`(新增 `authMode`/`configConsistent`/`diagnosticCode`/`diagnosticMessage`),在 `getCodexStatus` 中识别 CCS 等工具导致的 `config.toml` 与 `auth.json` 不一致(缺 key、auth_mode 不匹配、插件模式缺 bearer token、旧式配置残缺、配置污染等);前端新增 `useResponsesDiagnostics` composable,聚合 Codex 配置层、Responses 渠道层(无渠道/全禁用/无 key/熔断/协议错配)与最近失败日志层(401/403、429、5xx、超时)的诊断结论,并在状态页提供最高优先级问题摘要。第一版仅给出诊断与手动修复建议,不做自动修复。涉及 `desktop/internal/configservice/service.go`、`desktop/frontend/src/composables/useResponsesDiagnostics.ts`、`desktop/frontend/src/components/agent/*`、`desktop/frontend/src/components/status/StatusTab.vue`、`desktop/frontend/src/i18n/messages.ts`、`desktop/frontend/src/types/index.ts`\n- **桌面端 dashboard 显示运行态当前渠道** - 桌面端 dashboard 新增运行态当前渠道信息展示\n- **简化前端添加渠道流程** - 优化前端渠道添加交互流程\n\n### 修复\n\n- **桌面端 Codex 排障忽略 UI 选择的目标模式** - 排障诊断只检查 config.toml / auth.json 文件自洽性,未考虑用户在 UI 上选择的 Codex 模式(快捷/插件),导致选了快捷模式但磁盘仍是插件模式时误报”配置一致”。修复后前端排障会比较 `status.mode`(磁盘实际模式)与 `codexMode`(UI 目标模式),不一致时优先报模式冲突并提示重新应用。涉及 `desktop/frontend/src/composables/useResponsesDiagnostics.ts`、`desktop/frontend/src/i18n/messages.ts`\n- **桌面端 Codex 排障按需触发** - Codex 排障诊断改为按需触发,避免不必要的自动诊断开销\n- **桌面端渠道介绍 i18n 对齐** - 补齐渠道介绍缺失的 key 与中文产品描述\n- **桌面端 Fuzzy 模式 API 路径补全** - Fuzzy 模式 API 路径自动补全 `/api` 前缀并改为 watch 驱动加载\n- **桌面端熔断器配置预设值同步** - 同步熔断器配置预设值、slider 范围与 WebUI 一致\n- **桌面端 fetch 网络层错误包装** - 包装 fetch 网络层错误,避免管理面板显示原始 `Failed to fetch` 信息\n- **桌面端切换渠道目标时清除成功提示条** - 修复切换渠道目标时残留上一目标操作结果的问题\n- **桌面端补齐 claudeProviderKeys 缺失键** - 补齐 claudeProviderKeys 缺失的 openrouter/modelscope 键,修复 TS2345 类型错误\n- **桌面端当前渠道显示补上渠道名称** - 修复当前渠道显示缺失渠道名称的问题\n- **桌面端渠道预设描述统一** - 以平台身份重写渠道预设描述,统一 MiniMax M3 旗舰描述文案\n- **桌面端 agent 配置绑定同步** - 同步 agent 配置绑定关系\n- **按 content block index 管理 tool_use 缓冲** - 修复重叠 tool_use 导致 `Content block not found` 错误\n- **preflight 对未知 SSE 事件类型增加内容兜底识别** - Responses preflight 阶段对未知 SSE 事件类型增加内容兜底识别逻辑\n- **识别 messages→responses 桥接下的上游错误** - 修复 messages→responses 桥接场景下上游错误/失败/reasoning 事件未被正确识别的问题\n- **调整熔断器预设超时值** - 均衡档改为 60s/60s/180s\n- **渠道级流式超时预设与全局调校台对齐** - 渠道级流式超时预设值与全局调校台保持一致\n- **补齐渠道调校台参数同步** - 修复渠道级调校台参数未与全局配置同步的问题\n- **优化 RunAPI 渠道预设与排序** - 优化 RunAPI 渠道预设配置与列表排序\n- **tune 工具调用空闲处理** - 调整流式工具调用空闲超时参数\n- **加固本地 compact prompt** - 加固 Responses compact 本地提示词\n\n### 重构\n\n- **合并历史图片轮次限制到熔断器对话框** - 将历史图片轮次限制配置合并到熔断器对话框中,减少工具栏按钮数量\n- **从 EnvTab 移除熔断器配置卡片** - 从 EnvTab 移除熔断器配置卡片,统一由管理面板弹窗管理\n- **熔断器设置更名为调校台 Tuning Bench** - 将熔断器设置相关 UI 文案统一更名为”调校台 Tuning Bench”\n\n### 文档\n\n- **上游评估 TODO 更新** - 添加上游评估待办事项,记录 #190 新特性条目与上游协议变更\n- **upstream-check TODO 格式对齐** - 对齐上游检查脚本 TODO 分组标题层级与任务格式\n\n### 其他\n\n- **将上游检查脚本迁入技能目录** - 将上游版本检查脚本从 tools/ 迁入技能目录,优化技能目录结构\n\n## [v2.8.25] - 2026-06-07\n\n### 新增\n\n- **RunAPI 赞助商与桌面端直连预设** - 新增 RunAPI 赞助商展示、渠道中心预设与 Claude/Codex/OpenCode 直连配置,支持 Messages、Chat、Responses 三类目标\n- **流式 post-commit 阶段畸形工具调用截断策略** - 文本已输出后遇到畸形 `tool_use` 时缓冲并截断工具调用,注入 `end_turn` 与 `message_stop` 正常结束,避免客户端进入无效 `tool_result` 重试\n- **Responses 添加 tool_search 工具类型识别与兼容处理** - 新增 tool_search 工具类型的识别与兼容处理逻辑\n- **新增 stripImageGenerationTool 渠道开关** - 渠道配置新增 stripImageGenerationTool 开关,支持过滤图片生成工具\n- **桌面端同步 stripImageGenerationTool 渠道开关** - 桌面端编辑渠道界面同步支持 stripImageGenerationTool 开关\n- **上游版本每日自动检查 skill 与脚本** - 添加上游版本每日自动检查 skill 与脚本\n- **渠道管理界面增强与国际化完善** - Web UI 渠道管理界面增强与国际化完善\n- **新增流式响应观察者模式** - 流式响应新增观察者模式,支持可插拔的流式处理逻辑\n- **上游请求日志接入请求上下文标签** - 为上游请求日志接入 session/round 请求上下文标签\n- **新增请求日志上下文模块** - 新增请求日志上下文模块支持 session/round 标签\n- **流式响应 TPS 进度记录** - 记录流式响应 TPS 进度\n- **流式 200 伪成功检测与 failover 机制** - 新增流式 200 伪成功检测与 failover 机制\n- **Desktop 前端熔断器配置新增流式超时滑块** - 桌面端熔断器配置界面新增流式超时滑块\n- **Web 前端熔断器配置新增流式超时滑块** - Web 前端熔断器配置界面新增流式超时滑块\n- **Gemini/Images 流式 preflight 适配** - 完成 Gemini/Images 流式 preflight 适配\n- **Gemini handler 初步接入流式 preflight 检测** - Gemini handler 初步接入流式 preflight 检测\n- **Responses handler 流式两阶段 preflight 检测** - Responses handler 接入流式两阶段 preflight 检测\n- **Chat handler 流式两阶段 preflight 检测** - Chat handler 接入流式两阶段 preflight 检测\n- **Minimax 2.7 思考标签处理升级** - 升级 Minimax 2.7 思考标签处理为协议级提取与原生推理字段转换\n\n### 修复\n\n- **记录缓冲工具事件的原始流日志** - 修复 tool_use 缓冲期间原始 SSE 事件未写入响应日志的问题,确保工具开始、参数增量与结束事件可追踪\n- **渠道中心预设顺序与 RunAPI Responses 文案优化** - 保持后端预设顺序避免按 Key 分组导致列表跳位,并为 RunAPI Responses 目标显示原生 Responses 方案文案\n- **桌面端切换渠道时清除成功提示与错误状态** - 修复切换渠道时旧的成功提示和错误状态残留的问题\n- **修复三个疑似 bug (#162 #187 #188)** - 修复三个疑似 bug\n- **延长流式空闲超时限制** - 延长 stream idle timeout limits\n- **保留流式 preflight 诊断信息** - 保留 stream preflight diagnostics\n- **修复 Windows 图标透明度并补全 Codex 预设模板** - 修复桌面端 Windows 图标透明度并补全 Codex 预设模板\n- **添加 guide/ 重定向文件修复 GitHub 链接 404** - 修复 docs/guide 重定向导致的 GitHub 链接 404\n- **将完整工具调用视为非空流** - 修复完整工具调用被误判为空流的问题\n- **修复 JSON 日志截断破坏 Unicode 字符** - 修复 JSON 日志截断时破坏 Unicode 字符的问题\n- **修复跨协议能力测试虚拟协议检测** - 修复跨协议能力测试中虚拟协议检测逻辑\n- **修正 MSIX 数据目录显示路径** - 修正桌面端 MSIX 数据目录显示路径\n- **流式工具调用使用 idle timeout** - 修复流式工具调用使用正确的 idle timeout\n- **分离工具调用流式超时** - 将工具调用流式超时与普通流式超时分离\n- **修复 compact 流式转换状态 panic** - 修复 Responses compact 流式转换状态 panic\n- **捕捉已提交流式响应断流** - 修复已提交流式响应断流未被捕捉的问题\n- **移除流式超时 off 档并为渠道新增超时覆盖开关** - 移除流式超时 off 档并为渠道新增超时覆盖开关\n- **修复工具调用流式 preflight 误判** - 修复 Chat 工具调用流式 preflight 误判\n- **修复 restoreApiKey 接口请求路径** - 修复桌面端 restoreApiKey 接口请求路径\n- **修复编辑渠道对话框拉黑密钥恢复按钮不可点击** - 修复桌面端编辑渠道对话框拉黑密钥恢复按钮不可点击\n- **修复编辑渠道对话框拉黑密钥恢复按钮不可点击 (Web)** - 修复 Web 端编辑渠道对话框拉黑密钥恢复按钮不可点击\n- **规范化 compact reasoning 输出** - 修复 Responses compact reasoning 输出格式\n- **修复频道菜单缺失 i18n** - 修复桌面端频道菜单缺失 i18n 翻译\n- **对齐管理面板协议显示名** - 修复桌面端管理面板协议显示名不一致\n- **修复 Minimax 2.7 模型 think 标签问题** - 修复 minimax 2.7 模型有 `` 标签的问题\n\n### 重构\n\n- **按语言拆分 i18n 消息文件** - 前端 i18n 消息文件按语言拆分\n- **拆分 API 服务层类型和工具** - 前端 API 服务层类型和工具拆分\n- **移除 Wails v3 系统通知服务** - 桌面端移除 Wails v3 系统通知服务\n\n### 测试\n\n- **补充 RunAPI 桌面端预设与 Agent 配置回归测试** - 覆盖 RunAPI Messages/Chat/Responses 渠道 payload、官网链接、Claude 状态识别与 Key 复用、Codex 快捷/插件模式、OpenCode 直连配置\n- **GPT 类渠道探测模型覆盖 codex-auto-review** - GPT 类渠道探测模型新增 codex-auto-review 覆盖\n- **覆盖无模型映射的协议转换测试组** - 新增无模型映射的协议转换测试组覆盖\n- **拆分 chat_to_responses 测试文件** - 拆分 converters chat_to_responses 测试文件\n- **拆分 configservice 测试文件** - 拆分桌面端 configservice 测试文件\n- **拆分 failover 测试文件** - 拆分 common failover 测试文件\n\n### 文档\n\n- **更新 TODO 格式规范与疑似 bug 修复记录** - 更新 TODO 格式规范与疑似 bug 修复记录\n- **更新 TODO 完成状态与关键提交记录** - 更新 TODO 完成状态与关键提交记录\n- **添加 GPT 类渠道模型测试覆盖待办** - 添加 GPT 类渠道模型测试覆盖待办\n- **新增桌面端渠道成功提示清理待办** - 新增桌面端渠道成功提示清理待办\n- **精简历史版本记录** - 精简 CHANGELOG 历史版本记录\n\n### 其他\n\n- **新增 Serena MCP 配置与项目记忆** - 新增 Serena MCP 配置与项目记忆\n- **升级 Vite 和 Vitest** - 前端升级 Vite 和 Vitest 依赖\n- **合并 guide/ 重定向文件修复** - 合并 guide/ 重定向文件修复分支\n\n## [v2.8.24] - 2026-06-04\n\n### 修复\n\n- **修复深层嵌套图片检测遗漏导致视觉回退失效** - 修复嵌套结构中图片 URL 未被识别,导致视觉回退策略无法生效的问题\n\n## [v2.8.23] - 2026-06-04\n\n## [v2.8.22] - 2026-06-04\n\n### 修复\n\n- **修复桌面端管理面板 CORS 认证失败** - 修复 `OPTIONS` 预检请求被认证中间件拦截返回 401,导致桌面端 WebView 无法访问管理接口的问题;`ENABLE_CORS=false` 时仍为带 `Origin` 的请求注入 CORS 响应头\n- **version-bump 新增 CHANGELOG 覆盖率校验** - 版本升级流程新增 CHANGELOG 覆盖率校验,防止遗漏提交记录\n\n### 文档\n\n- **补全 v2.8.21 CHANGELOG** - 补全 v2.8.21 版本遗漏的 CHANGELOG 条目\n- **新增 Windows 桌面图标透明度待办项** - 新增 Windows 桌面图标透明度相关的待办任务\n\n## [v2.8.21] - 2026-06-04\n\n### 新增\n\n- **桌面端原生管理控制台** - 桌面端原生管理控制台替代 iframe WebUI,完整覆盖 i18n,管理入口统一命名为 Dashboard\n- **桌面端渠道管理重构** - 添加渠道简化为快速粘贴模式(支持 Cmd/Ctrl+Enter 创建),编辑渠道弹窗对齐 WebUI 核心交互,新增预设模板、模型拉取、高级选项分组、Base URL 预览、服务类型动态排序、模型映射协议预置列表与 filter chips、API Key 必填校验\n- **桌面端渠道操作增强** - 渠道卡片支持置顶置底并增加删除守卫,渠道日志弹窗重构为可展开列表并支持自动刷新,删除渠道时显示 loading 遮罩,模型重定向 target 输入框始终提供候选\n- **桌面端会话驾驶舱重构** - 会话驾驶舱重构为瀑布流卡片并对齐 WebUI cockpit,能力测试对话框对齐 WebUI,能力测试多 job 并发轮询修复\n- **桌面端 Codex 会话迁移** - 添加 Codex 会话迁移支持,快捷/插件模式切换对齐 WebUI\n- **CLI --backupdir 选项** - 新增 `--backupdir` 选项指定配置备份目录\n- **CLI 自定义运行时路径** - 支持自定义运行时路径\n- **LOG_DIR=none 禁用日志文件** - 支持 `LOG_DIR=none` 环境变量禁用日志文件写入\n- **渠道级请求超时配置** - 后端渠道配置新增 `requestTimeoutMs`,非流式上游请求优先使用渠道超时,未设置或为 0 时继承全局 `REQUEST_TIMEOUT`\n\n### 修复\n\n- **messages**: system 角色归一化对所有上游生效\n- **日志 store 改用 metricsKey 分桶** - 根治删除渠道后日志串桶问题\n- **模型过滤拒绝非法字符** - 模型过滤拒绝顿号等非法字符并按分隔符拆分\n- **余额不足/套餐过期自动拉黑** - 补全 403 关键词自动拉黑,改为双词组合匹配覆盖更多上游变体\n- **下架已停用 GPT 模型** - 从能力测试候选、模型重定向候选和预设中移除 `gpt-5.2` / `gpt-5.2-codex` / `gpt-5.3-codex`,haiku 映射改为 `gpt-5.4-mini`\n- **桌面端编辑渠道大量 UI 修复** - 快捷键与底部操作栏、Esc/Enter 快捷键、窄窗口布局、高级选项平铺 Runtime/Transport 分组、Website 移入连接分组、模型卡片布局、noVisionModels 合并行级 toggle、思考强度/Text verbosity 下拉修复、popover 主题变量补全、移除冗余 Latency 列与重复快捷键提示、保存快捷键统一为 Cmd/Ctrl+Enter\n- **桌面端快速粘贴优化** - Textarea 高度修正、自动应用识别结果、自动补全 name/serviceType、按钮提示显示平台对应修饰符、移除 Service Type 识别卡片\n- **桌面端 Dashboard 修复** - 移除轮询可见性检查修复渠道列表不自动刷新、管理面板 Tabs 配色对齐深色主题、备用池文案对齐 WebUI、驾驶舱命名对齐并移除手动刷新、网关监控按钮改为「打开管理面板」\n- **桌面端模型列表修复** - 拉取补传 key 参数修复 'No API key provided' 错误、源模型列表对齐 WebUI allSourceModelOptions、预设数据与按钮分组完全对齐 WebUI\n- **桌面端移除一键配置已下线模板** - 移除 gpt-5.3/gpt-5.2 模板,haiku 改映射到 gpt-5.4-mini\n- **桌面端渠道中心恢复原有 ChannelTab 界面** - 移除渠道测速按钮,补齐编辑弹窗快捷键\n- **桌面端 i18n 补全** - 编辑渠道界面、编辑弹窗新增 i18n keys 等缺失翻译补全\n\n### 重构\n\n- **桌面端管理入口命名统一** - 管理入口统一命名为 Dashboard,隐藏批量测速按钮,移除 Codex 分组冗余开关\n- **桌面端编辑渠道布局对齐** - 请求超时从基础信息移入 Runtime 分组,抽取渠道草稿保存逻辑统一提交路径\n- **渠道管理清理** - 移除未使用代码(开启 noUnusedLocals)、删除渠道编辑弹窗冗余元素\n\n### 文档\n\n- **Docker 部署示例修正** - 修正 Docker 部署示例与实际 compose 保持一致\n- **LOG_DIR=none 补充文档** - 补充 `LOG_DIR=none` 禁用日志文件文档及测试用例\n- **新增 TODO 待办任务文档**\n\n## [v2.8.20] - 2026-06-01\n\n### 新增\n\n- **渠道级请求超时配置** - 后端渠道配置新增 `requestTimeoutMs`,非流式上游请求优先使用渠道超时,未设置或为 0 时继承全局 `REQUEST_TIMEOUT`;五类渠道新增/更新路径均校验负数并支持清空回退。\n- **Web UI 渠道请求超时表单** - 渠道编辑表单新增“请求超时 (ms)”输入项,支持编辑态清空恢复继承全局,并在复制渠道、payload 序列化、差异检测和多语言文案中同步该字段。\n\n### 新增\n\n- **桌面端 Codex Agent 配置双模式** - Codex 支持两种 CCX 模式:快捷模式(OpenAI provider + CCX 代理,不迁移会话、不支持插件)和插件模式(CCX 原生 provider + `requires_openai_auth = true`,支持插件但切换需会话迁移提示)。UI 下拉框拆分为两个选项,互切时显示会话迁移警告。\n- **Codex 配置改用快捷/插件模式开关** - 桌面端 Codex 配置入口统一改为快捷模式/插件模式开关,明确不同接入路径。\n- **Codex provider 块统一使用 `env_key`** - 将 `[model_providers.xxx]` 块中的 `temp_env_key` 统一替换为官方文档字段 `env_key`。\n- **桌面端 AgentCard 编辑器打开支持多编辑器选择** - 配置文件和认证文件的\"用编辑器打开\"按钮现在支持多编辑器场景,通过透明 select 覆盖层提供编辑器选择下拉。\n- **渠道中心已保存 key 的 preset 优先排列** - 桌面端渠道中心将已保存 API Key 的 preset 优先展示,便于快速选择常用渠道。\n- **已配置 key 的渠道在渠道中心优先展示** - Web 前端渠道中心根据已配置 Key 状态优先排列渠道。\n- **Codex 渠道模板新增 codex-auto-review 重定向** - 渠道模板新增 codex-auto-review 重定向能力。\n- **新增 WebUI 新用户指引** - WebUI 增加新用户引导说明,帮助首次使用者快速理解渠道配置流程。\n- **OpenAI 直连增加 API Key 勾选框** - 桌面端 OpenAI 直连新增「我有自己的 API Key」勾选框。\n- **新用户指引添加渠道展示模拟表单** - 新用户指引增加渠道展示模拟表单,更直观展示快速模式字段。\n- **Codex 第三方 provider 支持快捷/插件模式切换** - 第三方 Codex provider 支持在快捷模式与插件模式之间切换。\n\n### 修复\n\n- **下架已停用 GPT 模型** - 从能力测试候选、模型重定向候选和预设中移除 `gpt-5.2` / `gpt-5.2-codex` / `gpt-5.3-codex`,haiku 层模型统一替换为 `gpt-5.4-mini`,并为桌面端渠道模板补充旧模型回归检查。\n\n- **浏览器直达移除 `ccx_desktop` 参数** - 修复浏览器直达 WebUI 时残留桌面端参数的问题。\n- **默认语言改为中文并增加浏览器语言检测** - 修复默认语言选择逻辑,新增浏览器语言检测。\n- **新用户指引协议切换说明拆分为三段** - 调整新用户指引中协议切换说明的展示结构。\n- **新用户指引简化标签列表** - 将新用户指引标签列表简化为 Claude / Codex / Gemini 等。\n- **新用户指引示例渠道改为 gpt-5.5 中转** - 更新新用户指引中的示例渠道文案。\n- **新用户指引删除 quickHint** - 删除 quickHint,改为说明 CCX 自动识别。\n- **删除新用户指引模拟表单及关联资源** - 删除模拟表单及其关联 i18n/CSS 资源,简化引导内容。\n- **新用户指引添加渠道说明拆分为两段** - 调整添加渠道说明,使内容层次更清晰。\n- **渠道置顶后保持渠道顺序** - 修复渠道置顶后顺序被打乱的问题。\n- **修正 Codex 认证配置模式** - 修复 Codex 认证配置模式写入与展示不一致的问题。\n- **渠道排序以 priority 优先,hasKey 仅作兜底** - 修复渠道排序中 hasKey 权重过高导致 priority 失效的问题。\n- **消除 Codex 非 OpenAI provider 时悬空分隔符** - 修复 Codex 非 OpenAI provider 表单中多余竖线分隔符。\n- **调整 Linux 数据目录到 XDG state** - 修复 Linux 桌面端数据目录位置,改用 XDG state 路径。\n- **掩码 Codex 配置预览密钥** - 修复 Codex 配置预览中密钥未正确脱敏的问题。\n- **修正 Codex `auth_mode` 为 `apikey`** - 修正 Codex 配置中的认证模式字段值。\n- **OpenAI 直连取消勾选 API Key 时清空残留值** - 修复 OpenAI 直连关闭自带 Key 后仍保留旧值的问题。\n- **修正 Codex 配置预览 `auth_mode` 被误掩码** - 修复配置预览中普通字段被误判为密钥的问题。\n- **对齐 Codex 插件模式认证配置** - 修复 Codex 插件模式下认证配置与预期不一致的问题。\n- **JSON 配置 diff 改为字段级掩码** - 修复 JSON 配置 diff 脱敏粒度过粗导致预览不准确的问题。\n- **修正短密钥掩码后 JSON diff 漏报变更** - 修复短密钥脱敏后 JSON diff 无法识别真实变更的问题。\n- **按字段掩码 Codex 配置 diff** - Codex 配置 diff 改为按字段掩码,避免误掩码普通配置项。\n- **OpenAI 直连自带 Key 必须输入** - 修复 OpenAI 直连勾选自带 Key 时允许空值的问题。\n\n### 重构\n\n- **移除左上角 Logo 点击跳转 GitHub 链接** - 移除 UI 左上角 Logo 的 GitHub 跳转行为。\n- **配置预览文件标签改用增删行数统计** - 桌面端配置预览文件标签改为展示增删行数统计。\n\n### 文档\n\n- **更新桌面安装文档** - 更新桌面安装文档,补充 Windows Store 已上架和 Homebrew 安装说明。\n- **排障指南添加各入口 curl 快速验证命令** - 为排障指南补充各协议入口的 curl 快速验证命令。\n- **排障指南首条添加关闭全局代理和 TUN** - 排障指南首条新增关闭全局代理和 TUN 的排查建议。\n\n### 其他\n\n- **修复 go fmt 格式化缩进** - 修正 Go 格式化后的缩进样式。\n- **移除 Claude-Reasoning-Debug 调试日志** - 移除 Claude-Reasoning-Debug 调试日志及 summarizeAssistantReasoningState 函数。\n- **精简新用户指引内容** - 移除新用户指引中的状态图例、开关和接入客户端三节。\n- **补充 desktop type-check 脚本** - 为桌面前端补充 type-check 脚本。\n- **固化 Wails3 CLI 安装版本** - CI 中固定 Wails3 CLI 安装版本,提升构建稳定性。\n\n## [v2.8.19] - 2026-05-30\n\n### 修复\n\n- **补全 MiMo target 描述删除遗漏** - 补全桌面端 MiMo target 描述删除时的遗漏处理。\n\n## [v2.8.18] - 2026-05-30\n\n### 新增\n\n- **按 ReasoningParamStyle 转换客户端原始 reasoning 参数** - 新增按 ReasoningParamStyle 配置项转换客户端原始 reasoning 参数的能力。\n- **Passthrough 路径 ReasoningMapping 分支补充 reasoning_effort 样式处理** - 在 passthrough 路径的 ReasoningMapping 分支中补充 reasoning_effort 样式的处理逻辑。\n- **桌面端 add Homebrew tap 自动化** - 新增 Homebrew tap 自动化发布流程。\n- **桌面端点击版本号触发手动更新检查并计入冷却时间** - 点击版本号可手动触发更新检查,并计入冷却时间避免频繁检查。\n- **桌面端 Codex CCX 代理切换为 openai_base_url 官方模式** - Codex CCX 代理从自定义模式切换为 openai_base_url 官方模式。\n\n### 修复\n\n- **渠道中心布局断点降低至 md 并修复窄屏 URL 溢出** - 修复渠道中心在窄屏下的布局问题,将断点降低至 md 并修复 URL 文本溢出。\n- **Responses API 返回 426 让 Codex WebSocket 回退到 HTTP POST** - 修复 Responses API 返回 426 状态码时 Codex WebSocket 正确回退到 HTTP POST。\n- **修复断裂文档链接,统一 PORT=3688,补充源码构建前置依赖** - 修复文档中断裂的链接,统一 PORT 配置为 3688,补充源码构建的前置依赖说明。\n- **MiMo messages 目标描述统一为通用表述** - 统一桌面端 MiMo messages 渠道目标描述为通用表述。\n\n### 重构\n\n- **移除熔断器弹窗标题图标** - 移除熔断器弹窗中的标题图标,简化界面。\n- **渠道中心添加目标标签改为产品名称** - 将渠道中心的添加目标标签改为显示产品名称。\n\n### 其他\n\n- **UI 熔断器配置改为预设按钮+滑块交互** - 将熔断器配置改为预设按钮+滑块的交互方式,删除重复的 .env 分组。\n- **UI 熔断器参数三列并排布局+间隔线** - 调整熔断器参数为三列并排布局并增加间隔线。\n- **UI 预设按钮移到滑块下方,参数列增加内边距** - 将预设按钮移到滑块下方,参数列增加内边距优化间距。\n\n## [v2.8.17] - 2026-05-29\n\n### 新增\n\n- **新增 gpt-5.4-mini 到 codex responses 协议源模型列表** - 前端 codex responses 协议源模型列表新增 gpt-5.4-mini 模型。\n- **更新 Gemini 源模型列表** - 更新前端 Gemini 源模型列表配置。\n- **更新模型列表并同步前后端** - 更新模型列表并保持前后端配置同步。\n\n### 修复\n\n- **WebUI iframe 认证使用 ADMIN_ACCESS_KEY 而非 PROXY_ACCESS_KEY** - 修复桌面端 WebUI iframe 认证使用错误密钥的问题。\n\n### 其他\n\n- **简化 favicon,替换为极简 CX 描边图标** - 简化 favicon 设计,替换为极简 CX 描边图标。\n\n## [v2.8.16] - 2026-05-29\n\n### 新增\n\n- **添加 DashScope Token Plan 预设** - 桌面端添加 DashScope Token Plan 预设。\n\n### 修复\n\n- **修正渠道一键预设显示** - 修正 UI 渠道一键预设显示问题。\n- **复用 Windows 安装器旧安装目录** - 桌面端 Windows 安装器复用旧安装目录。\n- **修正 Web Logo 主题配色** - 修正 Web Logo 在不同主题下的配色显示。\n- **修正暗色模式 Logo 配色** - 修正桌面端暗色模式下 Logo 配色。\n- **修正图标亮暗主题显示** - 修正 UI 图标在亮暗主题下的显示。\n- **隐藏桌面 WebUI 版本检测** - 隐藏桌面端内嵌 WebUI 版本检测。\n\n### 其他\n\n- **隔离 backend 端口选择测试避免读取真实 .env** - 隔离桌面端 backend 端口选择测试,避免读取真实 `.env`。\n\n## [v2.8.15] - 2026-05-29\n\n### 新增\n\n- **重设计应用图标与品牌 Logo** - 桌面端应用图标与品牌 Logo 全新设计。\n\n### 修复\n\n- **统一 BuildChannelView 高级开关字段输出** - 将 `normalizeSystemRoleToTopLevel`、`stripEmptyTextBlocks`、`passbackReasoningContent`、`passbackThinkingBlocks`、`injectDummyThoughtSignature`、`stripThoughtSignature` 六个高级开关字段统一收归 `BuildChannelView()` 公共函数输出,消除 messages/chat/responses/gemini 四个 handler 中的重复补丁,确保所有渠道类型的 WebUI 编辑回显与保存一致。\n- **移除 PreviewAgentConfigDiff 未使用的 status 变量** - 清理桌面端 PreviewAgentConfigDiff 组件中未使用的 status 变量。\n- **Agent 配置应用使用最新 .env 端口** - 桌面端 Agent 配置应用时使用最新的 .env 端口值。\n- **统一 Web UI Logo 与应用图标** - 统一 Web UI 中的 Logo 和应用图标,保持视觉一致性。\n- **补齐渠道一键模板配置** - 前端补齐渠道一键模板配置功能。\n- **调整 Opus 4.8 能力探测请求** - 调整 Claude Opus 4.8 模型的能力探测请求参数。\n- **修复预设渠道控制台网址和套餐显示** - 修复预设渠道中控制台网址和套餐信息显示不正确的问题。\n\n## [v2.8.14] - 2026-05-29\n\n### 新增\n\n- **新增 normalizeSystemRoleToTopLevel 开关兼容旧 Claude 上游** - 新增系统角色规范化到顶层字段的兼容开关,便于旧版 Claude 上游适配。\n- **统一桌面端与 Web UI 的 MiMo/DeepSeek 渠道预设配置** - 统一桌面端与 Web UI 的 MiMo、DeepSeek 渠道预设,保持跨入口配置一致。\n- **补全渠道中心全部预设的中英文 i18n** - 为渠道中心全部预设补齐中英文国际化文案。\n- **主题切换改为自动/亮色/暗色三按钮,默认跟随系统** - 桌面端主题切换调整为自动、亮色、暗色三按钮,并默认跟随系统主题。\n\n### 修复\n\n- **修复 Messages 嵌套工具结果图片未触发视觉回退** - 识别 `tool_result.content` 等嵌套 content 数组中的图片块,确保 MiMo 等配置 `visionFallbackModel` 的渠道在历史截图请求中正确切换到识图回退模型,并跳过 `noVision` 渠道。\n- **调整主题选项排序** - 调整桌面端主题选项展示顺序,使默认与常用选项更符合使用习惯。\n- **修复中文模式下渠道说明显示英文** - 修复中文界面中渠道说明仍显示英文的问题。\n- **修复渠道 index 为 0 时能力测试无法启动** - 修复前端能力测试在渠道 index 为 0 时被误判而无法启动的问题。\n- **修复桌面端内嵌 Web UI 能力测试无响应的问题** - 修复桌面端内嵌 Web UI 发起能力测试后无响应的问题。\n- **修复亮色模式下所有状态色文字对比度不足** - 调整亮色模式状态色文本样式,提升可读性。\n\n### 重构\n\n- **移除浏览器模式的 Wails mock 传输层** - 移除桌面端浏览器模式下的 Wails mock 传输层,简化开发模式通信链路。\n\n## [v2.8.13] - 2026-05-29\n\n### 新增\n\n- **添加 claude-opus-4-8 模型到能力探测与优先级列表** - 前端能力探测与模型优先级排序规则新增 claude-opus-4-8 模型支持。\n- **桌面端亮暗色主题切换** - 桌面端新增亮色/暗色主题切换功能,支持用户自定义界面外观。\n\n### 其他\n\n- **桌面端 golang.org/x/sys 提升为直接依赖** - 将 `golang.org/x/sys` 从间接依赖提升为直接依赖,确保依赖关系清晰。\n\n## [v2.8.12] - 2026-05-28\n\n### 修复\n\n- **Linux 桌面构建依赖与运行环境更新** - 更新 CI 中 Linux 桌面构建的系统依赖为 gtk4 与 webkitgtk-6.0,构建运行环境切换至 ubuntu-24.04,确保桌面端在最新 Linux 发行版上正常编译与运行。\n\n## [v2.8.11] - 2026-05-28\n\n### 新增\n\n- **桌面端浏览器直连 Vite dev 端口可走通初始化** - 开发模式下支持浏览器直接连接 Vite dev 端口完成初始化流程(mock Wails IPC),无需通过 Wails 桌面窗口即可调试前端。\n\n### 修复\n\n- **WebUI 快速添加渠道不再误解析配置键** - 修正输入解析,避免 `api_key:`、`url:` 等字段名被识别为 API Key;解析逻辑改为保护 URL 中的冒号,并增加常见配置键名白名单过滤。\n- **WebUI 快速添加渠道后新渠道始终置顶** - 修正快速添加流程中渠道定位方式,不再依赖 \"index 最大\" 的启发式;改为按渠道名称精确匹配新渠道,确保新渠道保持在第一位并触发促销期。\n- **Windows 首次启动语言检测按平台分支** - 桌面端分平台检测系统语言,修复 Windows 首次启动时语言显示为英文的问题。\n- **Linux AppImage 使用 Ubuntu 22.04 构建以兼容 GLIBC 2.35** - CI 构建环境从新版 Ubuntu 降级到 22.04,确保 AppImage 在较旧 Linux 发行版上正常运行。\n\n### 文档\n\n- **推荐下载卡片增加 gh-proxy 加速下载链接** - 为下载卡片添加 gh-proxy 加速镜像链接,提升国内用户下载体验。\n- **站点 hero 区域单行标题与 tagline 扩展** - hero 区域改为单行标题、tagline 扩展上游列表与 env 代码块高亮。\n\n## [v2.8.10] - 2026-05-28\n\n### 新增\n\n- **桌面端多语言切换** - 新增轻量 i18n 框架(zh-CN / en),默认英文并自动跟随系统语言,首次启动按系统语言选择、手动切换后持久化到 `ui-preferences.json`;DesktopService 暴露 `GetLanguagePreference` / `SaveLanguagePreference`;侧栏底部守护面板新增语言切换入口;Sidebar、Setup、Status(含 LogViewer、DiagnosticCard)、Agent(含 ProviderForm、ConfigDiffDialog)、Channel、Env、WebUI 等用户可见文案全部迁移到 `t()`,便于 Microsoft Store Windows 截图审核。\n- **桌面端 Agent 配置支持 OpenCode** - configservice 新增 `PlatformOpenCode` 平台、`OpenCodeProxyState`、JSONC provider 块解析与 `auth.json` 读写;默认 ccx provider 走 `http://127.0.0.1:/v1`,可选直连 DeepSeek/MiMo/Compshare/Kimi/GLM/MiniMax/DashScope/OpenCode Zen/Go;写入 `~/.config/opencode/opencode.jsonc` 与 `~/.local/share/opencode/auth.json`;前端 AgentTab/AgentCard/ConfigDiffDialog 渲染 OpenCode 卡片;同步补充中英文 OpenCode 客户端 Desktop 自动配置文档。\n- **静默版本检查替代 wails updater 自动弹窗** - 关闭 wails updater 30 分钟自动 `CheckAndInstall`(无差别弹窗,失败场景尤为干扰);新增 `desktopservice.CheckLatestRelease(force)` 直查 GitHub Releases,过滤 prerelease,服务端缓存 4 小时(错误 30 分钟);侧栏版本号旁追加「新版 vX.X.X」胶囊,仅有更新时显示,点击打开 release 页(Store 分发不显示);抽出 `useReleaseCheck` composable(启动 8s 首次检查、之后 4 小时一次);托盘菜单「检查更新…」入口保留,主动点击仍走完整 wails updater 安装流程。\n- **OpenCode 直连仅保留官方原生支持的国产渠道** - OpenCode 卡片下拉移除 MiMo/Compshare/DashScope(OpenCode 官方未原生支持),保留 ccx + DeepSeek/Kimi/GLM/MiniMax + Zen/Go;`useAgentConfig` 同步补齐对应 baseURL 分支。\n- **渠道中心 preset 文案 i18n 化并区分 plan 渠道名** - 新增 `translateOrFallback` / `tf` helper,preset/plan/target 文案按 key 翻译、缺失 key 回退到 Go preset 原文;新增 `preset-messages.ts` 仅维护 EN,zh-CN 复用后端中文;channelName 默认值在选中非默认 plan 时追加 plan suffix,避免 MiMo/DashScope 多套餐同名覆盖。\n\n### 修复\n\n- **/v1 等裸 API 前缀不再被 SPA 兜底成 WebUI** - `isAPIPath` 仅匹配带尾部斜杠的前缀(\"/v1/\" 等),导致 `GET /v1`、`GET /api`、`GET /admin` 这类裸前缀落到 NoRoute 的 SPA 兜底分支返回 `index.html`;改为同时匹配 `path == prefix` 与 `prefix+\"/\"`,并补上之前漏掉的 `/v1beta`(Gemini 原生格式);新增表驱动测试覆盖裸前缀、子路径以及 `/v1custom` / `/apifoo` 等非 API 路径。\n- **状态页 ServiceDetails 去除与 Sidebar 重叠的 PID/Health 信息** - 移除卡片内 PID 与健康状态行(Sidebar 已展示),同时去掉冗余小标题,避免与左侧栏视觉重叠。\n- **统一 Agent 卡片 Provider 下拉文案至 i18n** - Codex 卡片 ccx 选项原使用 `agent.localGateway`(\"当前 CCX 网关\"),与 Claude/OpenCode 卡片的 `agent.provider.localGateway`(\"CCX 本地网关\")不一致;OpenCode 卡片其余选项及\"访问官方控制台\"按钮、API Key placeholder 也是硬编码中文;统一改用 `agent.provider.*` 与 `agent.openConsole` i18n key。\n- **注册 mdi-head-snowflake 图标** - AddChannelModal 的\"回传 Thinking Blocks\"开关使用 `mdi-head-snowflake`,但 `vuetify.ts` 未导入,运行时图标回退到占位文本;补 `@mdi/js` 导入与 `iconMap` 映射。\n\n### 优化\n\n- **收紧 ServiceDetails 卡片上下内边距** - 桌面端状态页 ServiceDetails 卡片上下内边距收紧,提升信息密度。\n- **语言切换合入侧栏底部守护面板** - 桌面端语言切换入口整合到侧栏底部守护面板,与现有偏好控件统一布局。\n\n### 文档\n\n- **新增桌面端教程截图** - 为桌面端使用指南补充压缩后的 CCX Desktop 截图,并将端口引用对齐到默认 3688。\n\n## [v2.8.9] - 2026-05-27\n\n### 修复\n\n- **MiMo 计费目标 URL 不更新** - 修复桌面端 MiMo 计费配置切换后目标 URL 未正确更新的问题。\n- **Claude 渠道思考块透传缺失** - 修复 Claude 渠道返回 reasoning/thinking blocks 时未按配置回传的问题。\n- **Responses 透传渠道预设误展示 gpt-5.x 一键设置** - Responses 协议透传渠道隐藏不适用的 gpt-5.x 一键预设,避免误配置。\n- **导引完成后默认页签错误** - 桌面端首次导引完成后默认跳转 Agent 配置,而非渠道中心。\n- **Sidebar 导入路径错误** - 修复桌面端 `Sidebar.vue` 绑定导入路径使用 `@/bindings` 导致的问题,改为正确的 `@bindings`。\n- **Responses 到 Claude 缺少 max_tokens 默认值** - 修复 Responses → Claude 链路缺少 `max_tokens` 默认值的问题。(#133)\n\n### 其他\n\n- **补充 max_tokens 边界测试** - 为 Responses → Claude 转换链路补充 `max_tokens` 边界测试,覆盖默认值与异常边界。\n\n## [v2.8.8] - 2026-05-27\n\n### 新增\n\n- **DashScope Coding Plan 选项** - 桌面端 DashScope provider 新增 Coding Plan 计费选项,Claude 直连 Agent 配置支持 Coding Plan 选择器。\n\n### 变更\n\n- **桌面端自更新从手写 updater 改为 Wails v3 内置 Updater** - 删除 `desktop/internal/updater/` 包(含 macOS DMG 挂载、Windows NSIS 安装器、Linux AppImage nohup 替换等平台安装器,行为模式易被杀软误杀);移除 `CheckUpdate`/`DownloadAndInstall`/`CancelUpdate` Go 导出方法和 `UpdateInfo` 结构体;改用 Wails v3 `pkg/updater`(GitHub Releases Provider + SHA256SUMS 校验 + 内置更新 UI 窗口 + 30 分钟定时检查);删除前端 `useUpdater.ts` composable 和 `UpdateDialog.vue` 组件,Sidebar 版本按钮改为纯展示(更新检查通过托盘菜单触发);Wails v3 依赖从 alpha.95 升级到 alpha.96\n- **DashScope 按量付费计划标签对齐 MiMo 规范** - 统一 DashScope 渠道预设的按量付费计划标签显示,与 MiMo 规范保持一致。\n\n### 修复\n\n- **Chat 渠道选择 Responses 上游时请求格式与端点错误** - 修复 Chat 渠道 `serviceType: \"responses\"` 时仍以 Chat 格式发送到 `/v1/chat/completions` 的问题;新增 Chat↔Responses 双向协议转换(请求:messages→input, max_tokens→max_output_tokens;响应:output→choices),流式 SSE 事件转换(response.output_text.delta→Chat chunk),前端预期请求 URL 同步修正为 `/v1/responses`。(#130)\n- **规范化 role 开关提示不够明确** - 明确 i18n 中规范化 role 开关的提示文案,注明 developer 角色转换与国内模型建议。\n\n### 文档\n\n- **添加英文 CCX Desktop 指南和入口链接** - 新增英文版 CCX 桌面端使用指南与文档入口链接。\n\n## [v2.8.7] - 2026-05-27\n\n### 文档\n\n- **完善桌面端用户教程与客户端接入入口** - 补充桌面端使用教程与客户端接入文档入口说明。\n\n### 重构\n\n- **MiMo 渠道预设移除自定义 base URL,优化 token plan 切换与下拉标签** - MiMo 渠道预设不再设置自定义 base URL,同时优化 token plan 切换逻辑与下拉标签显示。\n- **go fmt 格式化 channelpreset 缩进对齐** - 对桌面端 channelpreset 模块执行 go fmt 统一格式化。\n\n### 修复\n\n- **含图请求不再覆盖普通文本 Trace 亲和** - 含图请求 failover 到视觉渠道成功后,不再改写同一用户的普通文本 Trace 亲和,避免 DeepSeek 等文本渠道被长时间绕过;`HasImageContentCached` 只读缓存,不影响已有 Images 渠道行为。\n- **恢复 BuildPayload Description 写入,移除 DMG 打包错误静默** - 恢复桌面端 BuildPayload 中 Description 字段的写入,移除 DMG 打包流程中对错误的静默处理以便排查问题,同时格式化相关测试文件。\n\n### 其他\n\n- **补充 NoVisionModels 断言覆盖** - 为桌面端 NoVisionModels 相关逻辑补充断言覆盖,增强测试完整性。\n\n## [v2.8.6] - 2026-05-26\n\n### 新增\n\n- **Images 渠道内置模型列表新增 gpt-image-2** - 前端 Images 渠道的内置模型选项新增 `gpt-image-2`。\n- **渠道中心 provider 官方控制台链接** - 桌面端渠道中心为各 provider 添加官方控制台访问链接,方便快速跳转管理;badges 与链接布局调整至标题同行,避免错位。\n- **macOS DMG 安装包自定义拖拽背景** - 新增自定义 DMG 背景图与安装布局,Applications 快捷方式与 .app 拖拽区对齐 FlyShadow 风格。\n\n### 变更\n\n- **Compshare 渠道预设调整** - 高级模型重定向(opus/sonnet/gpt-5.4)从 deepseek-v4-pro 改为 glm-5.1;Chat/Responses 渠道移除内置模型重定向(用户自行在客户端指定模型);视觉策略从全渠道 NoVision 改为仅 deepseek-v4-flash 关闭视觉,并设置 MiniMax-M2.7 为视觉回退模型。\n\n### 修复\n\n- **添加渠道不写入描述,新渠道插入列表首位** - 修复添加渠道时描述字段未写入的问题,新增渠道现在默认插入列表首位而非末尾。\n- **渠道预设视觉与重定向策略重构** - 统一桌面端渠道预设的视觉(NoVision)与模型重定向配置,修正多个预设中不一致的策略。\n\n## [v2.8.5] - 2026-05-26\n\n### 新增\n\n- **桌面端 Store MSIX 分发支持** - Windows 发布流程新增 Store MSIX 分发配置与打包脚本,为 Microsoft Store 分发补齐 manifest、package 脚本和 release 产物链路。\n- **桌面端 Compshare 推广入口** - 桌面端新增 Compshare 推广入口与外链打开能力,便于用户从侧边栏访问相关服务。\n- **Compshare Agent 直连支持** - 桌面端 Agent 配置新增 Compshare provider 选项,并同步支持直连配置状态识别与应用。\n- **Compshare 渠道预设** - 渠道中心新增 Compshare 预设,补充对应模型映射、服务类型和预设测试覆盖。\n\n### 文档\n\n- **隐私政策说明** - 新增中英文隐私政策页面,并将其接入文档导航。\n- **首页下载推荐展示优化** - 调整 README 首页下载推荐展示,并补充 VitePress 自适应下载推荐组件。\n- **首页自适应下载推荐** - 文档首页新增根据平台自适应展示推荐下载项的能力。\n\n### 其他\n\n- **合并 Compshare 桌面渠道支持** - 合并 Compshare 桌面渠道相关功能分支,纳入本次发布范围。\n\n## [v2.8.4] - 2026-05-25\n\n### 修复\n\n- **桌面端自动更新前停止后端进程** - 自动更新下载并校验安装包后,会先停止由桌面壳托管的 Go 后端,再启动平台安装器;如果当前后端由外部进程托管或停止失败,则中止更新并提示用户手动停止后重试,避免 Windows 安装时后端二进制被占用导致只更新 UI。\n\n## [v2.8.3] - 2026-05-25\n\n### 修复\n\n- **桌面端重启 CCX 服务后误报启动失败** - 网关监控重启服务时,停止与启动阶段改用独立超时上下文,避免停止耗时挤占启动健康检查时间;同时健康检查成功后清理过期错误状态,避免日志显示已重启成功但状态卡片仍展示启动失败。\n- **静默 health 轮询访问日志** - health 检查轮询现在遵循静默轮询日志配置,避免高频健康检查污染访问日志。\n- **桌面端窗口拖动时背景闪烁** - 桌面端窗口背板改用不透明背景,降低拖动窗口时的透明闪烁感。\n- **Logo 默认动画导致界面闪烁** - 关闭 Logo 默认动画,避免页面初始渲染和切换时出现不必要的视觉闪烁。\n- **Responses 单渠道模式未追踪对话数据** - 修复 Responses 单渠道成功请求未写入对话追踪器的问题,使驾驶舱能够正确展示单渠道对话数据。\n\n### 其他\n\n- **补充 Responses 单渠道对话追踪回归测试** - 为 Responses 单渠道成功请求新增 handler 级测试,验证请求会写入 `ConversationTracker` 并生成驾驶舱对话记录,防止 `/conversations` 再次为空。\n\n## [v2.8.2] - 2026-05-25\n\n### 新增\n\n- **桌面端最小化后隐藏 macOS Dock 图标** - macOS 下窗口最小化后同步隐藏 Dock 图标,减少后台运行时的 Dock 干扰。\n- **桌面端路径项支持打开目录/编辑器操作** - Agent 相关路径展示新增纯图标操作按钮,可直接打开目录或用编辑器打开配置文件。\n\n### 修复\n\n- **桌面端渠道预设模型重定向键统一为 `gpt-5`** - MiMo、MiniMax、OpenCode Zen、OpenCode Go 的 Chat / Codex Responses 单模型重定向现在统一使用 `gpt-5` 作为源模型键,匹配 Codex / Chat 客户端实际请求模型;同时集中整理渠道预设的模型映射、reasoning 映射和 Codex 兼容开关配置。\n- **桌面端中文系统默认语言仍为英文** - 环境配置表单在 `.env` 缺少 `APP_UI_LANGUAGE` 时会读取 WebView 暴露的系统/浏览器语言,中文系统默认填入 `zh-CN`,非中文系统继续默认 `en`,且不会覆盖用户已有 `.env` 设置。\n- **桌面端 MiniMax 渠道预设与 Codex 兼容配置不完整** - MiniMax Responses 预设现在使用 `gpt-5 -> MiniMax-M2.7` 模型映射,并启用非标准 Chat 角色归一化;同步补充渠道预设测试对归一化字段的断言。\n- **桌面端 Codex 应用 CCX 渠道时 auth 密钥不一致** - Codex Agent 配置现在优先读取桌面 dataDir、项目根目录和 `backend-go` 下 `.env` 中的 `PROXY_ACCESS_KEY`,再回退到进程环境变量或自动生成值,避免 `~/.codex/auth.json` 写入与实际后端配置不一致的密钥;同时增强 `.env` 解析以兼容 `export PROXY_ACCESS_KEY=...` 和等号周围空格。\n- **能力测试复制到其他 Tab 时原生协议类型错误** - 当能力测试中某个原生协议测试成功后复制渠道到其他协议 Tab 时,新增渠道的 `serviceType` 现在按成功协议映射为对应原生上游类型(Claude Messages / OpenAI Chat / Gemini / Responses),不再沿用源渠道的协议类型;转换按钮同时保留目标 Tab 与成功协议两个语义,避免复制到目标 Tab 时写入错误上游类型。\n- **桌面端初始配置完成状态误读环境变量** - `IsSetupComplete` 现在只读取桌面 dataDir 下的 `.env`,避免进程环境变量或项目根目录 `.env` 让首次启动引导被错误跳过,并补充对应单元测试。\n- **桌面端 Agent 状态路径展示溢出** - Agent 卡片中的当前/目标 URL、配置文件和认证文件路径改为固定标签列 + 可换行内容列,保留编辑器打开按钮且避免长路径挤压布局。\n- **桌面端网关监控详情字段未对齐** - 网关监控详情展示字段与后端服务状态字段保持一致,避免状态信息展示不完整或错位。\n- **桌面端托盘隐藏 Dock 图标时崩溃** - 修复托盘状态下触发隐藏 Dock 图标可能导致桌面端崩溃的问题。\n- **桌面端渠道预设刷新按钮点击报错** - 移除无效的刷新预设按钮,避免点击后触发前端异常。\n- **桌面端页面内容区宽度受限** - 移除页面内容区 `max-w-5xl` 限制,使桌面端页面能够自适应占满可用空间。\n\n### 文档\n\n- **补充渠道预设模型重定向说明** - 在变更记录中补充渠道预设模型重定向的说明,明确 Codex / Chat 客户端实际请求模型与预设映射关系。\n- **调整赞助商展示样式** - 优化 README 中赞助商展示区域的排版与视觉呈现。\n- **添加赞助商注册链接** - 在 README 赞助商信息中补充可直接访问的注册链接。\n- **添加优云智算赞助商信息** - README 新增优云智算赞助商介绍与展示图片。\n\n### 其他\n\n- **version-bump 增强 CHANGELOG 完整性校验** - 版本升级技能现在会校验从上一版本 tag 到 HEAD 的提交记录,补全或移动遗漏的 CHANGELOG 条目。\n- **桌面端 Wails 绑定产物切换为 TypeScript** - 当前工作区包含 `desktop/frontend/bindings/` 下生成绑定从 `.js` 文件删除到 `.ts` 文件新增的变更,需随本轮未提交改动一起审核。\n\n## [v2.8.1] - 2026-05-25\n\n### 修复\n\n- **桌面 App 内嵌 Web UI 删除渠道不生效** (#115) - Wails 3 的 WKWebView 在 iframe 上下文中对原生 `window.confirm()` 不弹对话框、静默返回 `false`,导致删除请求从未发出;替换为 Vuetify `v-dialog` 实现的通用确认框,兼容桌面 iframe 与浏览器双入口\n- **LogViewer autoScroll 开关失效,新日志仍强制滚动** - 移除对 `filteredLogs` 的冗余 watch,改为监听 `searchQuery` 仅在用户改变搜索关键字时强制滚动;新日志增加时改走受 `autoScroll` 控制的 `props.logs.length` watch\n- **缩短引导界面生成的 PROXY_ACCESS_KEY 长度** - 随机熵从 24 字节降为 8 字节,密钥从 `ccx-` + 48 字符 hex(52 字符)改为 `ccx-` + 16 字符 hex(20 字符);64 位熵对本地代理网关已足够安全,显著改善初始引导界面的可读性与可复制性\n\n### 文档\n\n- **修正渠道名称说明,反映同名覆盖的实际行为** - 后端 `createChannel` 在检测到同名渠道时会执行 PUT 覆盖更新,原文案 \"如重复,CCX 会拒绝创建\" 与实际行为不符,容易造成误导\n\n## [v2.8.0] - 2026-05-25\n\n### 修复\n\n- **TestDetectRootDirFallsBackToCwd 在 macOS `/private/var` 符号链接下的误报** - macOS 的 `/var` 是 `/private/var` 的符号链接,`t.TempDir()` 返回前者但 `os.Getwd()` 解析到后者,导致字符串直接比较失败;改用 `filepath.EvalSymlinks` 对齐期望值\n\n### 其他\n\n- **同步 channelpreset 测试期望与当前 preset 实现** - 修复 9 个因测试期望过时导致的失败用例,实现本身正确\n - 移除对 `SupportedModels` 字段的断言:`preset.go` 从未设置该字段(deepseek_chat 与 mimo 共 4 个用例)\n - `glm_chat` / `glm_responses` BaseURL 改为 coding plan 的实际值(`https://open.bigmodel.cn/api/coding/paas/v4#`),匹配 `bestPlanForTarget` 按 plan 数组顺序选第一个非 anthropic plan 的行为\n - `minimax_responses` 的 `CodexToolCompat` / `StripCodexClientTools` 改为 false:`applyTargetDefaults` 显式 override 为 native passthrough 策略,与 DeepSeek 一致\n - `TestBuildPayloadRejectsUnsupportedTarget` 改用 `invalid-target`:kimi preset 已支持 `NativeMessages`,原 kimi+messages 用例失效\n\n## [v2.7.31] - 2026-05-25\n\n### 破坏性变更\n\n- **桌面端 dataDir 路径稳定化,移除 sha1 hash 子目录** - 历史版本将 dataDir 计算为 `{UserConfigDir}/ccx-desktop/{sha1(rootDir)[:10]}`,rootDir 由 `os.Getwd()` 决定,导致从 Dock、Spotlight、终端 `wails3 dev` 等不同方式启动时产生不同 hash 目录(实测一台机器下出现 3 个互不相通的目录),用户的 PROXY_ACCESS_KEY、渠道配置、Agent 配置快照在不同启动方式之间相互不可见\n - 新版本统一使用 `{UserConfigDir}/ccx-desktop/`,与 `bootstrap.log` 同级,dev 与 prod 共用\n - **升级影响**:从旧版本升级后,原 hash 子目录(如 `ccx-desktop/42099b4af0/`)不会自动迁移,用户首次启动会进入空配置状态,需要重新通过引导页设置或手动从旧 hash 目录复制 `.env`、`agent-config-state/`、`.config/` 到新位置\n - 不做自动迁移的原因:无法可靠判断哪个 hash 目录代表\"主用户数据\"\n\n## [v2.7.30] - 2026-05-25\n\n### 新增\n\n- **桌面端首次启动引导流程** - 强制要求用户在启动 CCX 服务前完成最小可用配置,避免直接进入主界面后调用失败\n - 新增 SetupView 全屏引导页:检测 `.env` 中无 `PROXY_ACCESS_KEY` 时显示,预填随机生成的密钥(`ccx-<48hex>`),支持显示/隐藏、复制、重新生成\n - 完成后自动合并写入 `.env`(保留已有键值与注释),自动启动 CCX 服务并跳转至【渠道中心】标签页\n - 引导页展示 `.env` 文件路径方便后续调试,提示后续可在【环境参数】页继续修改其他字段\n - 新增 `IsSetupComplete` / `GenerateProxyAccessKey` 两个 Wails 绑定方法,前者只读判断密钥存在性,后者仅生成预览不写入文件\n - App 启动时三态渲染(Loading 占位 / Setup 引导页 / 主界面),避免界面切换闪烁\n\n- **桌面端 bootstrap 启动日志** - 在 backend manager 初始化前写入固定位置的 `bootstrap.log`,覆盖 dataDir 计算阶段的启动诊断盲区\n - 新增 `mustGetwd` / `mustExecutable` 辅助函数,记录关键启动阶段\n - 便于 Windows 双击无反应、dataDir 解析异常等场景的故障排查\n\n### 修复\n\n- **桌面端版本号显示重复 `vv` 前缀** - 修复桌面端左下角及更新弹窗版本号显示为 `vv2.7.29` 的问题\n - VERSION 文件已自带 `v` 前缀,前端模板不应再硬编码 `v`\n - 修正 `Sidebar.vue` 左下角版本徽标,以及 `UpdateDialog.vue` 标题处的当前/最新版本对比\n\n- **detectRootDir 向上遍历边界条件死循环** - 修复 `filepath.Dir` 到达根目录后返回自身导致的潜在死循环\n - 改为显式检测 `parent == dir` 终止\n - 新增测试验证 fallback 到 cwd 的行为\n\n### 其他\n\n- **NSIS 构建添加 `/INPUTCHARSET UTF8`** - 修复 NSIS 在非 ASCII 路径下编译失败的问题,并将 `ccx-go.exe` 构建产物加入 `.gitignore`\n- **新增 `make install` 命令** - 一键安装根目录、`backend-go/`、`frontend/`、`desktop/`、`desktop/frontend/` 全部依赖\n\n## [v2.7.29] - 2026-05-25\n\n### 修复\n\n- **批量延迟测试在 Chat 标签页报错 `e.forEach is not a function`** - 修复 Chat 渠道批量延迟测试时,前端 `pingAllChatChannels()` 未解包后端 `{\"channels\": [...]}` 响应导致 `forEach` 调用失败的问题\n - `api.ts` 中 `pingAllChatChannels()` 现在正确提取 `resp.channels` 并映射字段,与 Images/Gemini 处理方式一致\n\n- **第三方 provider 直连使用 ANTHROPIC_AUTH_TOKEN** - 修复桌面端第三方 provider(DeepSeek、MiMo、Kimi、GLM、MiniMax、DashScope、OpenCode)直连 Claude Code 时,API Key 错误写入 `ANTHROPIC_API_KEY` 的问题,改为正确写入 `ANTHROPIC_AUTH_TOKEN`\n - `resolveClaudeProvider` 统一将第三方 provider 的 key 归入 `authToken` 返回值,与 CCX provider 行为一致\n - apply、restore、preview 三条路径均通过同一函数解析,无需额外修改\n\n- **配置预览 Diff 掩码后丢失敏感字段变更** - 修复 Agent 配置预览弹窗中,敏感字段(如 `ANTHROPIC_AUTH_TOKEN`)变更时因掩码为 `***` 导致 diff 无法识别变更的问题\n - 新增 `computeJSONDiffWithMask` / `computeTextDiffWithMask`:先在原始内容上做 LCS diff 正确识别变更类型,再用掩码后的内容填充展示行\n - 更新全部 6 个 preview 函数(Claude/Codex apply & restore)使用先 diff 再掩码的方式\n - 新增 `extractNestedStringValues` 支持从嵌套 map(如 `env` 子 map)提取敏感字段值\n - 新增 4 个单元测试覆盖掩码 diff 核心场景\n\n## [v2.7.28] - 2026-05-24\n\n### 修复\n\n- **Codex 第三方 provider 状态识别与恢复逻辑修复** - 修复桌面端 Codex 直连模式下第三方 provider 的运行状态识别和恢复逻辑\n- **Codex Responses 转 Chat 时空 tool_choice 导致上游 400** - 修复当 Codex 请求仅携带非 function 类型 tools(全被过滤)时,`tool_choice` 和 `parallel_tool_calls` 仍被透传给上游 Chat API 导致拒绝的问题\n - `OpenAIChatConverter` 和 `ConvertResponsesToOpenAIChatRequest` 两条路径均增加 tools 存在性检查\n - 无 tools 时自动剥离 `tool_choice` 和 `parallel_tool_calls`,与透传路径 `stripCodexClientOnlyTools` 行为一致\n\n- **WebUI iframe 切换标签时自动刷新** - 修复桌面端切换标签页后 WebUI iframe 白屏的问题,切回 WebUI 标签时自动刷新内容\n\n- **Windows 桌面端双击无反应** - 修复 Windows 生产构建(`-H windowsgui`)下启动失败时错误静默吞掉的问题\n - 添加 Windows MessageBox 弹窗:WebView2 缺失、ccx-go 找不到等致命错误现在会弹窗提示用户\n - 添加顶层 `recover()` 兜底:未处理的 panic 不再导致进程静默退出\n - 添加单实例互斥锁:重复双击时弹窗提示\"已在运行中\",检查系统托盘\n - 添加持久化文件日志:`%APPDATA%\\ccx-desktop\\\\desktop.log` 记录启动日志便于排查\n - NSIS 安装器 `ccx-go.exe` 从可选改为必需,缺失则安装失败\n\n## [v2.7.27] - 2026-05-24\n\n### 新增\n\n- **Codex 直连新增 DashScope / OpenCode Zen / Go 选项** - 桌面端 Codex 直连配置新增 DashScope、OpenCode Zen 和 OpenCode Go 三个新的 provider 选项\n- **DashScope chat/responses 预设扩展** - DashScope 渠道预设增加 GPT 模型重定向和 reasoning mapping 支持\n\n### 修复\n\n- **Codex 直连目标 URL 显示** - 修复 Codex 直连时目标 URL 未正确显示各 provider 的 Responses 端点的问题\n\n## [v2.7.26] - 2026-05-24\n\n### 新增\n\n- **多渠道 Messages 原生透传支持** - Kimi、GLM、MiniMax 新增 Messages 原生透传,DeepSeek codex responses 预设默认开启原生工具透传和 role 规范化\n- **渠道预设扩展** - 新增 OpenCode Zen/Go 和阿里云 DashScope 渠道预设与 Agent 直连支持\n- **Agent 直连选项** - Agent 配置新增 Kimi / GLM / MiniMax 直连选项\n- **渠道中心自动覆盖** - 添加到 CCX 时自动覆盖同名渠道配置\n- **Web UI 刷新按钮** - 内嵌 Web UI 页面添加刷新按钮\n- **Codex OpenAI 切换** - 支持 Codex 无缝切换回 OpenAI provider\n\n### 修复\n\n- **渠道中心文案与 URL** - 修正描述文案错别字,移除 /v1 结尾 base URL 的 # 后缀\n- **渠道中心 target 切换** - 修复切换 target 后被级联重置回 messages 的问题\n- **桌面端二进制路径** - 修复 ccx-go 后端二进制未打包到 App Bundle 的路径错误\n- **vite 插件路径** - 修复 vite 插件路径为绝对路径并更新前端依赖\n- **OpenAI provider 配置** - 修复 OpenAI 模式下目标 URL 显示为空,移除无效配置块写入\n- **监控面板初始化** - 初始化时预探测二进制路径,避免监控面板显示\"未发现\"\n\n### 重构\n\n- **Web UI 刷新按钮位置** - 刷新按钮移至标题栏,WebUITab 通过 defineExpose 暴露刷新方法\n- **渠道预设精简** - 移除渠道预设中所有 SupportedModels 显式赋值,MiniMax 预设精简默认值\n- **ccx-go 查找路径** - 改为向上遍历最多 6 层目录\n\n### 其他\n\n- **Wails 升级** - Wails v3.0.0-alpha.92 升级到 alpha.95\n\n## [v2.7.25] - 2026-05-23\n\n### 修复\n\n- **渠道预设模型映射更新** - 更新桌面端渠道预设的模型映射配置\n- **WebUI 默认语言改为中文** - 前端 WebUI 默认语言从英文改为中文\n- **Agent 代理配置应用按钮文案统一** - 统一桌面端 Agent 代理配置的应用按钮文案\n- **Checksum 文件路径前缀修正** - 去除 CI Release 流程中 checksum 文件的路径前缀\n\n### 其他\n\n- **桌面应用图标资源更新** - 更新桌面应用图标资源文件\n\n## [v2.7.24] - 2026-05-23\n\n### 修复\n\n- **Release workflow Bad credentials 错误** - 修复 CI Release workflow 中 GitHub token 认证失败的问题\n\n## [v2.7.23] - 2026-05-23\n\n### 修复\n\n- **MiMo 不同计费模式支持独立 API Key** - ProviderKeyAsset 的 Assets map 改为 `provider:planID` 复合 key,同一 provider 不同 plan 不再互相覆盖;前端查找保存的 key 时优先匹配 plan-specific key,回退到 provider-only key 兼容旧数据\n\n## [v2.7.22] - 2026-05-23\n\n### 修复\n\n- **桌面端 CCX Provider 应用按钮状态修复** - CCX provider 的应用按钮不再依赖网关在线状态,避免网关未启动时无法操作\n- **Release checksum 路径修复** - 修复 Release checksum 生成路径错误导致三个主平台构建失败的问题\n\n## [v2.7.21] - 2026-05-23\n\n### 修复\n\n- **Release checksum 路径前缀修正** - 修复 CI release 流程中 checksum 文件路径前缀错误并精简 finalize job\n- **桌面构建 ccx-go 查找路径** - 修正桌面端构建时 ccx-go 后端二进制的查找路径\n- **渠道中心 Base URL 过滤** - 桌面端渠道中心按 target 协议过滤 Base URL 选项,避免显示不匹配的选项\n\n### 其他\n\n- **恢复 Wails 3 TypeScript 绑定文件** - 恢复桌面端 Wails 3 自动生成的 TypeScript 绑定文件\n- **version-bump 技能文档更新** - 更新 version-bump 技能的 GitHub Actions 集成说明,新增 `gh run list` 查看构建进度命令\n\n## [v2.7.20] - 2026-05-23\n\n### 修复\n\n- **MiMo 渠道模板 Base URL 修正** - 修正 MiMo 渠道模板 Base URL 为 Anthropic 协议入口,同时调整计费模式表单顺序\n- **网关监控默认显示配置端口** - 网关监控打开时默认显示 .env 中配置的端口,启动后端时优先使用用户配置的端口\n- **Windows Desktop amd64 安装包缺失** - 修复 CI 中 Windows Desktop amd64 安装包在 Release 中缺失的问题\n- **macOS 应用图标重新设计** - 重新设计应用图标以适配 macOS Human Interface Guidelines\n- **macOS 托盘图标交互修复** - 修复 macOS 托盘图标点击后窗口置顶和位置偏移的问题\n\n## [v2.7.19] - 2026-05-23\n\n### 修复\n\n- **日志文件混入精简格式重复内容** - 修复 logger 将 stdout 精简格式与原始完整 JSON 双重写入日志文件的问题,确保日志文件仅保留原始完整记录\n\n## [v2.7.18] - 2026-05-23\n\n### 修复\n\n- **DMG 安装体验与图标显示** - 改善 macOS DMG 安装包的安装体验与应用图标显示效果\n\n## [v2.7.17] - 2026-05-23\n\n### 修复\n\n- **Task 依赖链 ARCH 传递遗漏** - 修复 CI 中 Task 依赖链未正确传递 ARCH 环境变量的问题\n\n## [v2.7.16] - 2026-05-23\n\n### 修复\n\n- **桌面构建 ARCH 变量未通过 Task 依赖链传递** - 修复 CI 中桌面端构建时 ARCH 环境变量未正确传递到下游 Task 步骤的问题\n\n### 其他\n\n- **版本升级技能新增本地编译验证** - version-bump 技能在创建 tag 前强制执行本地测试和构建验证,编译失败时中止发布流程\n\n## [v2.7.15] - 2026-05-23\n\n### 变更\n\n- **移除后端 OTA 更新机制** - 删除后端 updater 包及相关 API 接口,桌面端更新改为跳转到 GitHub Releases 下载页面\n\n## [v2.7.14] - 2026-05-23\n\n### 新增\n\n- **桌面端 .env 密码字段复制按钮** - .env 配置中密码类型字段(如 `PROXY_ACCESS_KEY`)添加复制按钮,方便用户快速复制密钥值\n\n### 修复\n\n- **密码字段留空时隐藏复制按钮** - 当密码类型字段值为空时自动隐藏复制按钮,避免无效操作\n\n### 变更\n\n- **移除后端 OTA 自动更新** - 删除 `updater` 包、`/api/update/check` 接口及相关配置,桌面端更新改为跳转到 GitHub Releases 下载页面\n\n### 其他\n\n- **Release workflow 桌面构建前置修复** - CI 桌面端构建步骤前确保 `bin` 目录存在,修复构建流程中目录缺失问题\n\n## [v2.7.13] - 2026-05-23\n\n### 新增\n- **Linux ARM64 桌面端支持**:CI 新增 `ubuntu-24.04-arm` runner 构建 arm64 AppImage,updater 移除 arm64 限制。\n- **前端测试框架**:引入 vitest + jsdom,新增 `env-file.ts` 20 个单元测试。\n- **Bindings 同步 CI 守护**:新增 `check-bindings.yml` workflow,PR 修改 Go 接口时自动验证 TypeScript bindings 同步。\n- **桌面端 .env 文件外部编辑器打开**:Env 配置页新增\"用编辑器打开\"按钮,自动检测系统已安装的编辑器(VS Code、Cursor、Sublime Text、Vim 等),支持 `$EDITOR` / `$VISUAL` 环境变量优先。多个编辑器时提供下拉选择。\n\n- **Desktop 内置 ccx-go 后端**:macOS .app、Windows NSIS、Linux AppImage/deb/rpm 安装包现在内置 ccx-go 后端二进制,用户无需单独下载。Desktop 与 ccx-go 共用版本号。\n- **Release Sigstore 签名**:\n - 在 CI 发布流程中集成 Sigstore / cosign keyless signing,使用 GitHub Actions OIDC 颁发短期证书,无需管理密钥。\n - 三个构建平台(macOS / Windows / Linux)各自生成平台级 checksums 并签名,`finalize` job 合并为全平台 `checksums.txt` 后再次签名。\n - 新增 `docs/guide/verification.md` 与 `docs/en/guide/verification.md` 验证文档,说明 cosign 安装与签名验证步骤。\n - 现有 `.sha256` sidecar 文件与 updater 行为不变。\n\n### 变更\n\n- **桌面端 Env 配置访问控制置顶**:`PROXY_ACCESS_KEY` 和 `ADMIN_ACCESS_KEY` 配置组在 Env 配置表单中移到首位,便于首次配置时优先设置。\n- **补齐桌面端测试覆盖**:新增 `configservice`(19 个纯函数 + 集成测试)和 `backend/manager`(16 个测试)测试文件。\n\n### 修复\n- **MSIX 打包缺少 wails.json**:新建 `desktop/wails.json`,修复 `wails3 tool msix --config` 引用不存在的文件。\n- **parseEnvContent 兼容性**:Go 端 `.env` 解析支持 `export` 前缀和 `\\r\\n` 换行符。\n- **桌面端 Agent 配置服务初始化错误日志**:`configservice.New()` 失败时不再静默丢弃错误,改为输出 `[Desktop-Init]` 日志便于排查。\n- **渠道对话框下拉菜单位置偏移**:修复 Vuetify v-select/v-combobox 在添加/编辑渠道对话框内首次打开时菜单位置计算错误的问题,通过 eager 预渲染与 resize 触发确保下拉菜单正确定位。\n- **DeepSeek `user_id` 限速与隔离字段透传**:\n - 让内部会话标识提取支持 Chat 请求体顶层 `user_id`,用于 Trace 亲和性与调度追踪。\n - 补齐 Chat → Claude、Messages → OpenAI/Responses、Responses → Claude 跨协议转换中的 `user_id` / `metadata.user_id` 映射,避免用户隔离标识在协议转换时丢失。\n - 增加相关单元测试覆盖关键转换路径。\n- **Responses 转 Claude 上游 reasoning 回传补齐**:\n - 在 Responses 转 Claude/Messages 风格请求且启用 `PassbackReasoningContent` 时,同步补齐 `reasoning_content`,避免 MiMo 等上游跨渠道历史请求缺少 reasoning 回传字段。\n\n## [v2.7.12] - 2026-05-22\n\n### 修复\n\n- **Windows Release 构建 NSIS makensis 不在 PATH** - 修复 CI 中 Windows Release 构建时 NSIS `makensis` 工具不在 PATH 导致构建失败的问题\n\n## [v2.7.11] - 2026-05-22\n\n### 新增\n\n- **渠道预设 Token Plan 下拉显示实际 Base URL** - 桌面端渠道预设 Token Plan 下拉选项现在会显示实际的 Base URL,便于用户识别和选择\n\n## [v2.7.10] - 2026-05-21\n\n### 修复\n\n- **MiMo Messages 渠道流式响应只输出 thinking 的问题**:\n - 停止为缺少真实思考内容的历史 assistant 消息注入 `\"(no prior reasoning recorded)\"` 占位 `thinking` 或 `reasoning_content`,避免 MiMo 将正式回答续写进假 thinking block,或继续因假回传返回 `reasoning_content ... must be passed back` 400。\n - 为历史 assistant 消息补齐 MiMo 要求的顶层 `reasoning_content` 字段:保留真实 `thinking` 块原文并同步回传到顶层字段,仅移除历史请求中旧版本注入的占位/空 `thinking` 块;若 assistant 历史没有真实思考内容,则补空字符串 `reasoning_content: \"\"`(保持顶层字段存在);若历史 assistant 仅剩占位 thinking,则转为中性非空 text,避免空 content 或轮次丢失触发上游校验问题。\n - `thinking -> reasoning_content` 转换改为“保真搬运”:不再对真实思考文本做 trim/拼接改写;当消息已含顶层 `reasoning_content` 时保持原值,避免内容被改写后触发上游回传一致性校验失败。\n - 修复请求预处理误删真实 thinking 的问题:`thinking` 块里 `signature` 为空/null 时仅移除 `signature` 字段,不再删除整块 `thinking`,避免后续 `reasoning_content` 回传被掏空。\n - 增强 failover 非重试判定:当上游 400 的 `error.param`/`error.message` 命中 `reasoning_content in the thinking mode must be passed back` 时,判定为 schema 参数错误,不再继续 Key/渠道级 failover,避免请求漂移到其他渠道与错误熔断。\n - 流式预检测改为只有正式 text 或工具语义内容才视为有效响应;thinking-only 流会被判定为空响应并触发重试/失败,避免静默返回 200。\n\n## [v2.7.9] - 2026-05-20\n\n### 修复\n\n- **Gemini 渠道流式响应下 tool call ID 生成策略优化**:\n - 在 `backend-go/internal/providers/gemini.go` 中,将 Gemini 流式响应下 functionCall 的 tool ID 生成策略从不稳定的索引拼接形式(如 `toolu_%d`)优化为稳定的、带有 `call_` 前缀的 16 字节随机 hex 字符串(通过 `crypto/rand` 生成),使 ID 在跨多轮对话时更具唯一性且对齐主流(如 OpenAI `call_xxx` 风格),避免在复杂 agent 多轮调用中发生冲突。\n - 在 `backend-go/internal/providers/gemini_stream_test.go` 中添加对应的测试断言,验证 tool call ID 的 `call_` 前缀生成逻辑。\n- **修复 Gemini 流式响应中 functionCall 场景的 `stop_reason` 映射**:\n - 在 `backend-go/internal/providers/gemini.go` 中,引入 `hasToolUse` 状态标志。当流式响应中检测到 functionCall 时,将 `message_delta` 的 `stop_reason` 正确映射为 `tool_use`(而不是默认的 `end_turn`),确保下游消费端能够正确感知并启动工具调用执行,完全符合 Claude 规范。\n - 在 `backend-go/internal/providers/gemini_stream_test.go` 中新增 `TestGeminiHandleStreamResponse_FunctionCallMapsStopReasonToToolUse` 单元测试,模拟 functionCall 流式响应并验证 `stop_reason` 映射。\n\n## [v2.7.8] - 2026-05-20\n\n### 重构\n\n- **清理 `providers` 包历史 lint 告警**:\n - `backend-go/internal/providers/openai.go` 中 `ConvertToClaudeResponse` 转换 tool_call 入参时,给 `json.Unmarshal` 补上错误检查;解析失败时降级保留 `toolCall.Function.Arguments` 原始字符串,避免静默丢失参数(errcheck)。\n - `backend-go/internal/providers/openai_stream_usage_test.go` 中 `TestOpenAIProvider_ConvertToClaudeResponse_CacheFieldInJSON` 给 `json.Unmarshal` 补上 `t.Fatalf` 错误检查,防止 unmarshal 失败时后续断言基于空 map 假阳性通过(errcheck)。\n - `backend-go/internal/providers/responses.go` 删除未被任何地方调用的 `formatFunctionCallHistory` 函数(13 行):与配套 `formatFunctionCallOutputHistory` 不对称——`function_call` 分支始终直接保留原始 item,不需要降级为文本消息,该函数是早期设计残留(unused)。\n - `backend-go/internal/providers/claude.go` 中 `ConvertToProviderRequest` 模型重定向条件 `upstream.ModelMapping != nil && len(upstream.ModelMapping) > 0` 简化为 `len(upstream.ModelMapping) > 0`(Go 对 nil map 的 `len()` 定义为 0,gosimple/S1009)。\n\n## [v2.7.7] - 2026-05-20\n\n### 修复\n\n- **`messages` 走 Gemini 上游时 tool_result 函数名错位导致工具调用沉默丢失**:\n - 修复 `backend-go/internal/providers/gemini.go` 的 `convertMessage` 在转换 Claude `tool_result` 为 Gemini `functionResponse` 时,把 `name` 字段直接填成 `tool_use_id` 的问题。Gemini 协议要求 `functionResponse.name` 必须等于前面对应 `functionCall.name`(函数名),否则上游无法匹配到对应的工具调用,会沉默返回空内容(典型表现:MCP / 多轮工具历史在 Gemini 上游被静默丢弃,下游模型回合显示空白)。\n - 新增 `buildToolUseIDNameMap`,在 `convertMessages` 入口先扫一遍 Claude 历史,构建 `tool_use_id → name` 映射;`convertMessage` 接收该映射并在 tool_result 转换时回查正确函数名。查不到映射的孤立 tool_result 回退使用 `tool_use_id` 兜底,避免完全丢字段。\n - 在 `backend-go/internal/providers/gemini_tool_result_test.go` 中将历史 fixture 升级为含 tool_use 的完整对话,并新增针对 id→name 映射回查的断言用例。\n\n### 新增\n\n- **Chat 渠道透传思考回传支持**:\n - 在 `backend-go/internal/handlers/chat/handler.go` 中引入了与 Messages 渠道一致的预处理逻辑,自动清理空 signature 字段和历史畸形 thinking 内容块,预防上游参数校验 400 错误。\n - 在 `buildProviderRequest` 中,当 `PassbackReasoningContent` 关闭时,在发送给 Claude 协议上游前自动剥离历史 thinking 块,避免跨上游复用签名导致 signature 错误。\n - 在 `backend-go/internal/config/config_chat.go`、`channels.go` 和 `channel_metrics_handler.go` 中同步支持了 `PassbackReasoningContent` 字段的更新与返回。\n - 修改了 `frontend/src/components/AddChannelModal.vue`,使得“回传 Reasoning Content”开关在 Chat 渠道且服务类型为 claude 时也能正常显示和配置。\n - 在 `deepseek_thinking_matrix_test.go` 中新增了 `TestChatHandler_PassbackReasoningContent` 测试用例。\n\n### 修复\n\n- **`messages` 走 Gemini 上游时 MCP / 自定义工具历史丢失导致重复调用**:\n - 修复 `backend-go/internal/converters/responses_to_gemini.go` 中 `responsesItemToGeminiContents` 漏掉 `custom_tool_call` 与 `custom_tool_call_output` 两类 item 的问题。归一化阶段会把 MCP 工具(如 `mcp__serena__*`)落到这两种类型,原实现走 switch 的隐式 default 直接丢弃,导致历史里只剩模型空回合,Gemini 视为\"工具调用未返回\"并不停重复发起同一工具调用。\n - 现在两类 item 分别转为 `model` 角色的 `FunctionCall`(携带 `DummyThoughtSignature`,与 `function_call` 行为一致)和 `user` 角色的 `FunctionResponse`,并对缺 `call_id`/`name` 的孤立项做兜底丢弃,避免构造出 Name 为空的 part 触发上游 400。\n - 在 `backend-go/internal/converters/gemini_responses_roundtrip_test.go` 新增 `TestResponsesToGeminiRequest_PreservesCustomToolCallHistory` 和 `TestResponsesToGeminiRequest_CustomToolCallOutputWithoutCallIDDropped` 两条回归用例。\n- **SUBSCRIPTION_NOT_FOUND 余额不足故障转移**:\n - 将 `SUBSCRIPTION_NOT_FOUND` 错误码和 `\"no active subscription found\"` 错误信息识别为余额不足(`insufficient_balance`),从而正确触发渠道黑名单和故障转移。\n - 在 `failover_test.go` 和 `stream_test.go` 中补充了对应的普通请求与流式请求测试用例。\n- **Gemini 渠道 tool_result 数组解析报错**:\n - 修复了 `messages` 接口转上游 `gemini` 类型渠道时,`tool_result` 包含数组(Content Blocks)导致的反序列化报错问题。\n - 将 `GeminiFunctionResponse.Response` 字段类型从 `map[string]interface{}` 变更为 `interface{}`,提高结构体容错性。\n - 在 `backend-go/internal/providers/gemini.go` 的 `convertMessage` 方法中,对 `tool_result` 的 `content` 进行了智能解析和规范化,确保转换后的 `response` 始终是一个符合 Gemini 官方协议要求的 JSON 对象。\n - 新增了 `gemini_tool_result_test.go` 单元测试,覆盖了 `tool_result` 为数组、字符串、JSON 对象等各种场景,验证其能正确转换为 Gemini 期望的格式。\n- **Gemini 渠道空 text part 触发上游 400**:\n - 修复了 `messages` 接口转上游 `gemini` 类型渠道时,Claude assistant 消息中常出现的空 text 块(如带 tool_use 时的前置 padding)被无脑翻译为 `{\"text\": \"\"}`,被严格按 Gemini protobuf 校验的上游(如 vip.undyingapi.com)判定为 `contents[X].parts[Y].data: required oneof field 'data' must have one initialized field` 返回 400 的问题。\n - 在 `backend-go/internal/providers/gemini.go` 的 `convertMessage` 中处理 `text` 类型 content 时跳过空字符串,确保不向 Gemini 上游下发无意义的空 Part。\n - 在 `gemini_tool_result_test.go` 中新增 `TestGeminiProvider_ConvertMessage_SkipsEmptyTextBlock` 与 `TestGeminiProvider_ConvertMessage_KeepsNonEmptyTextBlock` 两条用例,覆盖空 text 块被剔除与非空 text 块仍保留两种场景。\n- **Claude→Gemini provider 转换遵循 thought_signature 渠道开关**:\n - 修复了 `messages` 接口转上游 `gemini` 类型渠道时,Claude 协议本身不携带 thought_signature 字段,导致严格校验的上游(如 vip.undyingapi.com)返回 `Function call is missing a thought_signature in functionCall parts` 400 的问题。\n - 在 `backend-go/internal/providers/gemini.go` 的 `convertToGeminiRequest` 中按上游 `injectDummyThoughtSignature` / `stripThoughtSignature` 开关注入 part 层级的 `thoughtSignature`,与原生 Gemini 入口(`handlers/gemini/handler.go`)的策略对齐:默认不修改、`injectDummyThoughtSignature` 开启时注入 `DummyThoughtSignature`、`stripThoughtSignature` 优先级更高且在该场景为 no-op(Claude 协议本就无签名)。\n - 在 `gemini_tool_result_test.go` 中新增 `TestGeminiProvider_ConvertToGeminiRequest_InjectDummyThoughtSignature`、`TestGeminiProvider_ConvertToGeminiRequest_DefaultNoSignature`、`TestGeminiProvider_ConvertToGeminiRequest_StripThoughtSignatureNoOp` 三条用例。\n- **补齐多渠道 thought_signature 字段链路**:\n - `backend-go/internal/config/config_messages.go` 的 `UpdateUpstream` 补充接收 `StripThoughtSignature`(之前漏了,开启了等于无效)。\n - `backend-go/internal/config/config_chat.go`、`config_responses.go` 的 update 函数补充接收 `InjectDummyThoughtSignature` 和 `StripThoughtSignature`,对齐 [[feedback_channel_config_field]] 的\"五类渠道更新函数必须同步应用新字段\"规则。\n - `backend-go/internal/handlers/messages/channels.go`、`chat/channels.go`、`responses/channels.go` 的 `GetUpstreams` 把这两个字段透出给前端;`handlers/channel_metrics_handler.go` 的 dashboard 端点同步补充。\n - `frontend/src/components/AddChannelModal.vue` 把 `injectDummyThoughtSignature` 开关的显示条件由仅 `props.channelType === 'gemini'` 改为 `(gemini || messages) && form.serviceType === 'gemini'`;`stripThoughtSignature` 开关的显示条件由仅 gemini 改为 `form.serviceType === 'gemini'` 且 channelType 属于 gemini/messages/chat/responses。chat/responses 渠道当前在 handler/converter 层默认无条件注入 dummy 签名,因此前端不暴露 `injectDummy` 开关以避免误导。\n- **`maskKey` 短密钥脱敏过度**:\n - 修复了 `backend-go/main.go` 中启动日志 `maskKey` 对长度 ≤ 4 的密钥直接输出 `****`、完全遮蔽用户能识别的字符的问题。\n - 现在对长度 ≤ 3 的密钥保留首字符(如 `abc` → `a****`),长度 4~8 保留首尾字符,长度 > 8 保留前后各 2 字符。\n\n### 新增\n\n- **CCX 桌面外壳 MVP** - 新增 Wails3 桌面外壳,用于将现有 CCX 后端作为核心服务构件进行启动、停止、重启、托盘驻留和状态监控;外壳通过现有 `/health` 探活并在内置标签页中加载 CCX Web UI,避免改动核心代理、调度和现有 Web 管理界面逻辑。\n - 新增 `desktop/` Wails3 项目,包含后端子进程 supervisor、托盘菜单、状态页、内嵌 Web UI 标签页和前端绑定。\n - 根 `Makefile` 新增 `desktop-dev` / `desktop-build`,复用现有前端 embed 与 Go 后端构建流程。\n - 调整 `.gitignore`,保留 Wails `desktop/build/` 源配置,同时继续忽略桌面构建产物。\n\n### 修复\n\n- **桌面外壳 Wails Runtime 422** - 升级 `wails3` CLI 与 `@wailsio/runtime` 客户端到 alpha.92 / alpha.79,匹配 alpha.79 引入的 binding transport refactor(请求改用 JSON body),消除旧 CLI(alpha.40)读 URL query 时的 `missing object value` 422 错误,桌面事件订阅(`desktop:show-tab` / `desktop:tray-error`)和 typed binding 调用恢复正常;同步升级前端工具链 vite 8 / vue-tsc 3 / typescript 6 / vue 3.5。\n- **空/畸形 Tool Call 自动重试** - 在 Fuzzy 模式下将空参数或非法 JSON 的 tool/function call 视为空响应并复用现有 failover,降低上游偶发 `Read({})` 等畸形工具调用对下游客户端的影响\n - 影响模块:Messages/Chat/Responses/Gemini 的非流式空响应判定、Messages/Responses 流式预检、Chat 流式写头前缓冲预检\n\n## [v2.7.4] - 2026-05-18\n\n### 修复\n\n- **Mimo thinking mode 兼容性** - 修复 mimo(小米 MiMo)等 reasoning model 上游在多轮对话切换场景下返回 400 \"The reasoning_content in the thinking mode must be passed back to the API\" 的问题\n - 之前实现将 thinking 块转为顶层 `reasoning_content` 字段(OpenAI 风格),实测 mimo 的 Anthropic 协议下不认顶层 `reasoning_content`,仍返回 400\n - 新实现保留所有原有 thinking 块,对缺少 thinking 块的 assistant 消息注入占位 thinking 块 `{\"type\":\"thinking\",\"thinking\":\"(no prior reasoning recorded)\"}`,让 mimo 通过 thinking mode 校验\n - 仅在渠道开启 `passbackReasoningContent: true` 时生效,对其他渠道无影响\n - 注意:旧对话切到 mimo 时模型上下文缺少真实推理内容,可能出现幻觉/指令遵循下降(mimo 官方公告明示的代价)\n- **OpenAI Provider 缓存 Token 丢失** - 修复 OpenAI Provider 在协议转换(OpenAI/DeepSeek → Claude Messages)时丢弃上游 cache usage 字段的问题(#76)\n - 流式:`HandleStreamResponse` 现在从 terminal usage chunk(`choices: []`)中提取 `prompt_cache_hit_tokens`、`prompt_tokens_details.cached_tokens` 等字段,并注入到 final `message_delta.usage` 中\n - 非流式:`ConvertToClaudeResponse` 新增二次 raw parse,将 DeepSeek/OpenAI 格式的 cache 字段映射到 `CacheReadInputTokens`\n - Metrics:`annotatePromptTokensTotalForProvider` 扩展到 OpenAI Provider,确保缓存命中归一化口径与 Responses Provider 一致\n\n## [v2.7.3] - 2026-05-18\n\n### 优化\n\n- **Docker 构建优化** - 使用 Go 交叉编译替代 QEMU 模拟,优化多架构镜像构建流程并增加层缓存,显著缩短 CI 构建时间\n\n### 修复\n\n- **Mimo reasoning_content 回传修复** - 修复 `convertThinkingToReasoningContent` 在将 thinking 块提取为 `reasoning_content` 字段时未从 content 数组中移除 thinking 块的问题;当从 Claude 原生渠道切换到 mimo 渠道时,残留的 thinking 块导致 mimo 上游返回 400 \"The reasoning_content in the thinking mode must be passed back to the API\" 错误\n\n## [v2.7.2] - 2026-05-17\n\n### 新增\n\n- **缓存读写总统计** - 全局统计图表和模型统计图表新增缓存 Token 读写统计展示\n - 后端:`ModelHistoryDataPoint` 新增 `cacheCreationTokens`/`cacheReadTokens` 字段;`GetModelStatsHistory` 和全局统计的模型分桶聚合逻辑补充缓存 Token 累加\n - 前端 `GlobalStatsChart`:Summary cards 和 compact summary 新增缓存 R/W 统计(有数据时显示);Tokens 图表视图动态添加 Cache Read/Write 系列线\n - 前端 `ModelStatsChart`:新增 Cache 视图切换,展示按模型分组的缓存 Token 趋势\n - 前端 `api.ts`:`ModelHistoryDataPoint` 类型补充缓存字段\n\n## [v2.7.1] - 2026-05-17\n\n### 新增\n\n- **渠道统计对齐接入点统计** - 渠道 Key 趋势图新增 7d/30d 时间维度,并展示总请求次数、成功率、输入/输出 Token 汇总卡片,与接入点总览统计对齐 (#72)\n - 后端:`HistoryDataPoint` 补充 Token 字段;`MetricsHistoryResponse` / `ChannelKeyMetricsHistoryResponse` 新增 `summary` 汇总;Key 趋势接口移除 24h 上限,支持 30 天范围 SQLite 聚合\n - 前端:`KeyTrendChart` 新增 7d/30d 按钮、summary cards、长时间范围 x 轴日期格式\n - SQLite:新增 `idx_records_api_type_metrics_key_timestamp` 复合索引优化渠道级长范围查询\n\n### 改进\n\n- **Override 熔断自动清除** - 当驾驶舱设置的 override(next channel)序列中所有渠道均不可用(熔断)时,调度器自动清除该 override 而非仅跳过,避免前端 NEXT 标签长期残留\n- **渠道熔断状态下发前端** - `GetConversationChannelsByKind` API 返回的渠道信息新增 `circuitOpen` 字段,前端驾驶舱可实时显示渠道熔断状态(FUSED 标记)\n\n### 修复\n\n- **Claude 协议空 Text Block 兼容开关** - 修复严格校验的第三方 Claude 协议上游因 Claude Code 在 `tool_use` 前插入裸空 `{\"type\":\"text\",\"text\":\"\"}` 占位块而返回 400 的兼容性问题;新增 Messages 渠道 `stripEmptyTextBlocks` 开关,在转发前按需移除空 text block,并同步接通前端配置、渠道视图与回归测试。\n- **Responses SSE keep-alive** - 为 Responses 流式代理增加 SSE keep-alive 机制,每 15 秒向下游发送 `: keepalive` 注释行,防止 DeepSeek 等慢上游思考期间触发 Codex 客户端 idle timeout 断连 (#67)\n\n## [v2.7.0] - 2026-05-17\n\n### 新增\n\n- **Responses Compact 本地压缩** - 当 responses 渠道上游为非原生 Responses 类型(openai/claude/gemini)时,`/v1/responses/compact` 端点自动切换为本地 compact 模式:将对话历史格式化为 transcript,通过现有 converter 管线发送普通请求让模型生成摘要,再包装为 Responses 格式返回。支持流式/非流式跟随客户端、session 历史读取与 compact 结果写回、大输入截断保护。原生 responses 上游若返回 404/405/501 也会自动回退本地 compact\n- **SessionManager 新增只读查询与压缩会话创建** - 新增 `GetSessionByResponseID` 通过 responseID 只读查找 session;新增 `CreateCompactedSession` 创建压缩后的轻量会话并记录映射\n- **ResponsesProvider 提取公共请求构建方法** - 新增 `ConvertBodyToProviderRequest` 公共入口,接受 bodyBytes 参数复用现有 URL/转换/认证逻辑,供 compact 等场景调用\n\n### 改进\n\n- **Messages 渠道模型列表兼容国内 Claude 协议入口** - 当 `base_url` 以 `/anthropic`、`/claude`、`/messages` 结尾时(如 `https://api.deepseek.com/anthropic`),模型列表获取自动尝试三段候选 URL:当前路径 → 剔除协议尾段 → 纯域名根路径,解决国内服务商模型接口不在兼容协议子路径下的问题。管理端\"获取模型\"同步适配。\n\n---\n\n## 历史版本(v2.0.0-go ~ v2.6.99)\n\n> 以下为早期版本的里程碑摘要,`v2.7.0` 及之后保留完整条目。更细粒度的历史变更可通过 `git log` 查看。\n\n### v2.6.x(2026-02 ~ 2026-05)\n\n- **渠道能力测试** - 完成 4 协议互转、Codex 自定义工具兼容、RPM/超时/取消/重定向验证、结果缓存与多协议并发轮询。\n- **独立 Images 渠道** - 新增 `/v1/images/generations`、`/v1/images/edits`、`/v1/images/variations`,接入独立 failover、metrics、日志和前端管理。\n- **熔断器三态状态机** - 引入 `closed` / `open` / `half_open`、指数退避、单探针恢复、持久化和 Dashboard 状态展示。\n- **渠道日志生命周期** - 支持 `pending`、`connecting`、`first_byte`、`streaming`、`completed`、`failed`、`cancelled` 全链路追踪与前端实时刷新。\n- **会话调度看板** - 新增 Conversation Dashboard、ConversationTracker、OverrideManager 与按对话覆盖渠道序列能力。\n- **Responses Compact 本地压缩** - 非原生 Responses 上游自动通过 converter 管线执行本地 compact,并写回轻量会话。\n- **Vision 能力路由** - 新增 `noVision`、`noVisionModels`、`visionFallbackModel`,按图片输入动态跳过或降级模型。\n- **Thinking / Reasoning 兼容** - 支持 DeepSeek、MiMo、Gemini 等上游的 `thinking`、`reasoning_content`、`thought_signature` 双向转换和回传。\n- **Codex 工具兼容层** - 支持 Codex `apply_patch` 代理工具、字符串工具、native tool passthrough、工具调用回写和 `codexToolCompat` 统一开关。\n- **模型与路由能力增强** - 支持模型过滤包含/排除规则、`X-Channel` 指定目标渠道、渠道级代理、自定义请求头、多 BaseURL 等价去重。\n- **缓存与指标口径统一** - 归一 Responses / Messages 缓存命中率、Token usage、Key / Model 维度统计和历史分桶。\n- **自动恢复机制** - 支持 UTC 0/8/16 时段自动恢复黑名单 key、half-open 探测和 suspended 渠道最小激活。\n- **前端体验与性能优化** - 完成多语言管理界面、移动端导航、能力测试弹窗重构、SVG/Tooltip 常驻资源收敛和内存优化。\n- **关键修复** - 覆盖 Fuzzy 模式 5xx 误判、空响应 failover、metadata.user_id JSON 对象兼容、401 字符串认证错误拉黑、客户端取消误计失败等问题。\n\n### v2.5.x(2026-01)\n\n- **Failover 逻辑模块化** - 提取通用 failover 分类、重试和黑名单判定逻辑。\n- **Gemini 兼容增强** - `thought_signature` 可配置,补齐 Gemini CLI tools schema 与 Dashboard 字段。\n- **指标记录架构优化** - 修复二次计数、客户端取消计数、换 key 后历史累计、渠道删除后串桶等问题。\n- **渠道管理增强** - 支持渠道置顶/置底、前端模型映射智能选择、隐式缓存读取推断。\n- **关键修复** - 包括 Gemini functionDeclaration parameters 类型、Models API 日志标签、响应 model 字段改写可配置化。\n\n### v2.4.x(2025-12 ~ 2026-01)\n\n- **Gemini API 完整实现** - 新增 Gemini API 模块、路由、历史指标和前端管理界面。\n- **统计图表增强** - 新增全局流量 / Token 图、Key 趋势图、缓存命中率统计和多模型趋势展示。\n- **低质量渠道处理机制** - 支持 `lowQuality` 渠道标记、暂停清理促销期和输出验证日志。\n- **配置模块化拆分** - 按渠道类型拆分 config 包,减少单文件配置膨胀。\n- **多端点调度优化** - 支持多 BaseURL failover、预热排序、非阻塞动态排序和并发竞争修复。\n- **关键修复** - 覆盖流式工具调用分裂、空 signature 导致 Claude API 400、内容审核无限重试、403 预扣费降级、SSE 事件完整性等问题。\n\n### v2.3.x(2025-12)\n\n- **渠道级 API Key 策略与多 BaseURL** - 支持多 key / 多 URL 管理、促销期状态展示和延迟测试优化。\n- **SQLite 指标持久化** - 引入本地指标存储,补齐 Responses API Token usage 与 Messages 缓存 TTL 细分统计。\n- **快速添加渠道** - 支持协议自动检测、引号 / 等号 / 空格分割、`settings.json` 解析和多 Base URL 输入。\n- **Models API 端点** - 新增模型聚合查询能力并完善 API Key / Base URL 去重。\n- **关键修复** - 包括滑动窗口重建、Responses usage 缺失、缓存 token 检测、total_tokens 零值补全。\n\n### v2.2.x ~ v2.1.x(2025-12)\n\n- **Handlers 模块重构** - 将 handlers 重构为同级子包结构,完善 Stream 错误处理与 CountTokens 安全加固。\n- **指标系统重构** - 指标绑定到 Key 级别,修复熔断器、单渠道路径指标和非 failover 错误计数。\n- **错误处理与历史数据** - 新增 Fuzzy Mode 错误处理开关、渠道指标历史数据 API、Key 级使用趋势图和失败率可视化。\n- **协议兼容增强** - 支持 TransformerMetadata、CacheControl、BaseURL `#` 结尾语义和 FinishReason 统一映射。\n- **稳定性修复** - 覆盖请求体大小限制、goroutine 泄漏、数据竞争、优雅关闭、流式日志合成器类型等问题。\n\n### v2.0.x(2025-10 ~ 2025-12)\n\n- **Go 版本初始发布** - 完成核心功能移植、单文件部署和性能提升。\n- **核心组件落地** - 引入 ChannelScheduler、MetricsManager、TraceAffinityManager 等基础模块。\n"}, "4da6d77e15a5c2ac4bc15ce1ad1a68cfdbd2e2d0": {"t": 1785846868.7199655, "n": 114679, "h": "734953b7098f3dc6", "c": "## [Unreleased]\n\n### 新增\n\n- **订阅中心与密钥管理体验** - 订阅中心改为渠道中心式 Provider 快捷接入,支持一键添加 GitHub Copilot、new-api 与手动订阅;三家赞助商展示真实 Logo/徽标并移除 Unity2.ai;API Key 支持手动暂停/恢复,模型覆盖按 Key 集合归组,火山折叠摘要及 Kimi/Compshare/MiniMax 余量改为统一五档阈值着色,Kimi 同时展示订阅总用量、赠送额度和加油包余额\n- **Autopilot Trace v2 契约与脱敏边界** - 新增 TraceDetailV2/TraceSummary/SchedulerDecisionSummary/EndpointAttemptSummary DTO,ComparisonStatus 三态枚举(matched/mismatched/uncompared)替代裸 bool,SanitizeForPersistence/SanitizeForResponse 双边界脱敏,crypto/rand 碰撞安全 TraceUIDv2,策略指纹(排除比例/seed),稳定哈希分桶,v1→v2 读适配器;新增 40+ L1 表驱动测试\n- **SQLite v6 迁移、持久化与清理** - autopilot schema v5→v6 幂等迁移;trace 表新增 schema_version/trace_revision/request_correlation_id/release_id/policy_fingerprint/persistence_class/details_json 列;窗口表重建主键增加 release/policy/cohort 维度和 compared/matched/mismatch/uncompared 计数;safety event 增加 release/policy 字段;in-flight 索引和异常提升落盘;有界异步 writer(512 队列/128 终态预留/64KiB 上限/250ms deadline/1s drain);7 天 trace + 30 天窗口清理策略\n- **Trace 关联接入请求生命周期** - 入口生成 requestCorrelationId 写入 gin context;选择渠道后写入 AutopilotTraceUID;ChannelLog 新增 RequestCorrelationID/AutopilotTraceUID 字段和 ChannelLogOption;BuildPlan 写入 dry-run trace(必落盘);NormalizeSelectionTrace 将 SelectionTrace 规范化为安全摘要并 AttachSchedulerDecision;AppendEndpointAttempt 有序/容量受限/乱序合并的 attempt 摘要\n- **endpoint attempt 摘要接入实际请求链路** - SetAttemptRecorderHook + recordAttemptCompleted hook 模式;upstream_failover 在 CreatePendingLog 后记 \"started\",成功/错误时记 \"completed\";main.go 注册 hook 连接 TraceStore;fail-open 观测失败不影响代理请求\n- **受控发布与自动降级** - active 模式加入配置枚举/归一化/IsAutopilotActive;RolloutPercent/ControlPercent/ReleaseID/RolloutSeed 配置字段和 validateRollout 校验;ReleaseController 集中管理状态迁移(逐级晋升/降级随时允许)、稳定分桶、EvaluateAndApplyRegression 三窗口回归降级、SafetyOverride 安全覆盖;接入 SmartRouter 请求路径(入口冻结 RoutingReleaseSnapshot);release/policy/cohort 隔离聚合\n- **只读 Trace API 与前端详情** - GET /api/autopilot/traces/:traceUid 详情端点(404/503/partial 契约、2s 查询 deadline);列表 API 改为 TraceSummary 输出,支持 release/cohort/mode 过滤和游标分页;统计新增三态比较计数;前端 AutopilotTraceDetailDialog 决策→Scheduler→尝试→终态时间线;AutopilotTraceTable 适配 TraceSummary;ChannelLogsDialog 跳转入口\n- **分层测试(L1-L5)** - L1 纯函数 40+;L2 SmartRouter+TraceStore 集成 8;L3 SQLite 生命周期 12(重启还原/坏 JSON/过期清理/迁移幂等/脱敏);L4 HTTP 契约 9(列表/详情/统计/404/脱敏/分页);L5 真实上游 smoke 2(默认 t.Skip);SSE golden 回归 6(帧顺序/trace 不改输出/取消/脱敏)\n- **Autopilot 模型与思考强度联合路由** - 自动路由的决策单元从「模型」提升为「模型 + 思考强度」的原子组合,解决模型被改写为异构上游后仍沿用客户端原档位、落在从未评测过的能力点上的问题。新增 `ResolvedRouteTarget` 作为贯穿全链路的唯一决策载体;`NormalizeEffortLevel` 统一证据侧/能力侧/请求侧的厂商档位命名,`IsUnpinnedEffort` 使 `default` 类未固定档位证据只能作降级 fallback 而非冒充精确匹配;`rankEligibleModels` 按 `SupportedEffortLevels × PerTaskClass` 展开 model×effort 候选,`betterRankedModel` 加入「够用即止」平手规则防档位膨胀,`filterEffortFloor` 按任务类下界过滤且全被过滤时 fail-open;`upstream_failover` 原子改写 model + 思考参数(任一步失败整体放弃),含 `adaptive_only` 守卫与 Gemini `thinkingConfig.thinkingLevel` 原生注入路径;`ManualRoutingIntent` 新增可选 `Effort` 字段(未知值校验拒绝,客户端显式 off/none 优先于意图锁定);`ChannelLog` 新增 `effortDecisionSource`/`effortClampedByClient`(仅在客户端上限实际生效时置位)与 Trace `effort` 维度;`PreferAutoOverManual` 开关支持手工映射优先级反转(默认关闭,可零代码回滚),新增 `GET /health-center/profile-coverage` 只读画像覆盖率诊断作为手工表退役前置门槛\n- **火山 Agent Plan AFP 成本感知路由** - 新增火山 AFP 政策目录与纯计算器、套餐作用域解析与可比较性判断、AFP 请求快照与候选级成本证据;AFP 折扣接入 SavingsScore 参与评分并写入 trace,compshare 单次减扣同样纳入;新增 `frontierRoutingEnabled` / `afpCostRoutingEnabled` 两个正交开关,AFP 成本路由默认开启\n- **确定性 Pareto 分层与三倾向候选阶梯** - 候选排序改为确定性 Pareto 分层,输出成本/质量/均衡三倾向阶梯;模型候选排序重排并接入 CostPreference\n- **渠道预检异步化与后台 discovery 断点续传** - 渠道预检不再阻塞添加流程,后台 discovery 支持断点续传\n- **多协议自动发现补全** - 补全多协议自动发现并自动启用已发现协议路由;自动发现改从 `apiKeyConfigs` 取密钥;协议探测改为逐模型验证;快速探活改为多模型候选策略(含回退测试);自定义协议渠道构造统一\n- **发现 RPM 应用与账号级 429 scoped cooldown** - 接线自动发现出的 RPM 到运行态限速,429 冷却收敛到账号级作用域\n- **连接耗时采集驱动 SpeedTier** - 新增连接建立耗时指标并参与速度分级\n- **弃用参数自动发现与记忆落盘** - 上游不支持的参数按渠道-Key-模型维度自动发现并剥离,记忆落盘后重启免重新探测\n- **渠道兼容性开关统一自学习** - 六项兼容性开关(`NormalizeNonstandardChatRoles`/`StripImageGenerationTool`/`CodexNativeToolPassthrough`/`StripEmptyTextBlocks`/`PassbackReasoningContent`/`PassbackThinkingBlocks`)从「用户手工勾选的静态布尔」彻底收归「运行时按渠道-Key-模型自动学习并持久化的能力事实」:字段已从 `UpstreamConfig`/`UpstreamUpdate` 整体删除,管理面板不再提供编辑入口,渠道更新接口不再接受手工写入,用户此后无需手工干预。新增 `ChannelCompatCache`(落盘 `.config/channel_compat.json`,TTL 24h,键 `channelUID:keyHash:model`)统一记忆六项兼容能力,最终生效值按 `学习结论 > 历史种子 > 静态默认` 合成。老用户配置文件里残留的历史手工值不代表可信的长期事实(可能当初就设错,也可能上游后来有了新情况),因此启动时 `migrateManualCompatSwitchesToSeeds` 从磁盘原始 JSON 读出历史值,一次性降级为渠道级、TTL 14 天的一次性低置信度种子(`CompatSeeds`):迁移当下不改变可感知行为,但学到真实结论后立即让位,过期后也不再参与判断;种子 TTL 与学习记忆 TTL 分开存储(14 天 vs 24 小时),避免混淆。该迁移按数组下标把原始 JSON 对到当前渠道,因此必须排在 `mergeManagedProviderAccounts` 之前——后者会把同 provider 的多账号渠道合并成一条(数组变短重排),顺序颠倒会让种子写到错误渠道或随被合并渠道丢失;`mergeManagedProviderAccounts` 同时补上了合并时携带 `CompatSeeds`,避免被合并掉的渠道的历史证据丢失。`Clone()` 一并深拷贝 `LearnedCompatTraits`,避免克隆已注入结论的 upstream 时共享 map。按信号强度分三条学习路径:有硬 400 信号的走 `CompatTraitFromError` 错误驱动(识别后同 Key 立即重试,改写在下一轮注入本次请求专属的 `LearnedCompatTraits`),其中 Codex `/v1/responses` 打到不支持 `developer` role 的上游时新增 `DowngradeDeveloperRoleToSystem` 窄改写(降 `system` 而非 `user`,保留开发者指令语义);仅能靠响应形态判断的 `passbackReasoningContent`/`stripEmptyTextBlocks` 复用渠道诊断探针,改为首次遇到该组合时 singleflight 后台自动探测一次,替代原先要用户手工点按钮;图片生成受限从「直接拉黑 (Key,模型)」改为「先学习剥离图片工具并重试,剥离后仍失败才回落拉黑」。所有识别规则采用双条件门控(正则命中 + 请求侧确实携带对应结构),且 429/5xx/超时一律不参与兼容性学习,避免把容量问题学成能力缺失。`RuntimeUpstreamForAutoManagedProvider` 连种子一并清空,自动托管渠道的兼容性完全由厂商原生默认加运行时学习决定\n- **Kimi 参数硬约束迁入 model-registry 刷新链路** - Kimi K3/K2.7-code/K2.6 的 temperature/top_p/n 固定值、tool_choice/thinking 约束不再是本地硬编码前缀表,改为 `UpstreamModelCapability.ParamConstraints`,随 `shared/model-registry/ccx_model_registry.json` 走 presetstore 的定期刷新链路发布;运营者核对厂商文档后更新 JSON 即可让线上实例在下一个轮询周期生效,不需要重新编译发版\n- **Kimi 按量模型参数硬约束前置规避** - `ApplyKnownParamConstraints` 按厂商文档在发送前规避已知约束,无需先失败一次:`kimi-k3`/`kimi-k2.7-code` 系列/`kimi-k2.6` 的 `temperature`/`top_p`/`n`/`presence_penalty`/`frequency_penalty` 为固定值,传入即 `invalid_request_error`,故直接剥离(上游用自身默认值,语义无损);`kimi-k2.6`/`kimi-k2.7-code` 不支持 `tool_choice: \"required\"`,降级为 `auto`;`kimi-k2.7-code` 的 `thinking` 仅接受 `{\"type\":\"enabled\",\"keep\":\"all\"}`,非法配置归一化为该值。`moonshot-v1` 系列的可改参数不受影响\n- **注册 Claude Opus 5** - 新增 Claude Opus 5 模型注册并支持 4.8 点号别名\n- **模型能力基准数据源扩展** - 接入 Artificial Analysis 数据源并纳入 opus-5;benchlm 改用官方 `models.json` 取代对比页抓取;benchmark-update 增加页面变更检测缓存层;补全 DEEPSWE/BENCHLM/DRADAR 白名单 CCX 模型映射与 kimi-k3、grok-4.5、muse-spark-1.1 等模型,DEEPSWE cohort 扩展至 17 模型;刷新 model registry 至 20260726\n- **trace 与账号信息可读性** - trace 候选渠道展示渠道名与 Key 掩码;账号列表返回脱敏 access token;`UsageMeter-Record` 日志补充渠道/接口/状态上下文\n- **auto 模式 SLO 回归保护可配置** - SLO 回归自动降级改为开关控制\n- **模型清单展示改进** - 共有/协议专属模型少于 10 个时直接展开列出,其余默认两行、仅实际溢出时显示展开\n- **火山套餐 Key 保活 per-key 路由与共享探针** - `config.BaseURLsForKey` 让已绑定端点的 Key 只在自己端点探测,不参与渠道级 BaseURL 笛卡尔积;新增 `internal/upstreamprobe` 共享火山 Agent/Coding Plan 数据面探针(autopilot 验证与 healthcheck 保活共用,避免请求特征漂移);`L1Response.RealCallVerified` 标记真实调用,火山 L1 成功后同周期跳过等价 L2 避免重复消耗额度;L2 探针副本覆盖绑定 BaseURL,recordFailure 归因到 Key 实际绑定端点\n- **渠道级实测上下文上限自学习** - 模型注册表登记的是模型公开窗口,个别渠道对某个模型的实际窗口更短(中转商自行截断、上游按套餐限制),长上下文请求会反复吃 400 `context_too_large`。新增按 渠道-Key-模型 维度记忆实测上限的数值型能力覆盖(`config.ContextLimitState`,复用 `ChannelCompatCache` 键空间/24h TTL/落盘,但独立于布尔型 `CompatTrait` 存储):`ContextLimitFromError` 从 400/422 报错识别超限信号,上游明确声明窗口值时直接采信,仅表示「太长」时按被拒请求量的 7/8 反推保守上界,并排除 `max_tokens`/请求体过大/图片尺寸/配额等相邻错误避免误判;合成规则「宁小勿大」多次学习取最小值,只在遇到更严格证据时收紧,放宽只能靠 TTL 过期重学;`SmartRouter.buildChannelEntry` 取 `min(注册表窗口, 实测上限)` 让上下文硬约束按真实容量判断,路由发生在选定 Key 之前故取该渠道-模型在所有已知 Key 上的最小值。上下文上限按自身 `LearnedAt` 独立计算 TTL(不共用 `entry.DetectedAt`,否则 trait 命中刷新会让实测上限被无限续期);无记忆时 fail-open,反推上界低于 4096 不采信。兼容性记忆单例上移为 `config.SharedChannelCompatCache`,供写入方 handlers 与读取方 autopilot 共享\n\n### 修复\n\n- **兼容性自学习块位于 failover 判断之后导致生产环境从未生效** - `upstream_failover.go` 的弃用参数自适应与渠道兼容性自学习(developer role 降级、Codex 工具剥离等)两个学习块原本写在 `if shouldFailover` 分支之后。而错误分类以「大多数非 2xx 都 failover」为默认策略,未命中已知不可重试关键词的 400/422 会被判定为 `shouldFailover=true` 并提前 `continue`,导致这两个学习块在默认配置下几乎从未被执行到——只有极少数恰好命中窄关键词表的报错才能落到它们。现已移至 `shouldFailover` 判断之前,与上下文上限自学习块位置一致;新增用真实 `TryUpstreamWithAllKeys` 驱动的集成测试钉住「上游 422 → 学习 → 同 Key 重试成功」完整闭环\n- **422 兼容性学习分支不可达** - 兼容性自学习块声称覆盖 400/422,但被嵌套在外层 `resp.StatusCode == 400` 守卫内,422 分支实际永远不可达(部分上游用 422 表达请求结构不受支持);改为与弃用参数块平级的兄弟块——弃用参数识别只针对 400,兼容性学习覆盖 400 与 422,二者条件不同不应嵌套\n- **Chat 入口 Gemini 上游响应被误判为空** - `handleSuccess` 的 `default` 分支承接 gemini 等没有专属 case 的透传上游类型,却统一使用只认识 OpenAI `choices[].message` 结构的 `IsChatResponseEmpty` 判空;对 Gemini 的 `candidates` 结构,`respMap[\"choices\"]` 断言必然失败而被判定为空,导致所有合法 Gemini 响应都误触发 failover、最终以 503 收尾。改为按 `candidates` 字段(Gemini 专属、非 omitempty)区分 schema 后走 `IsGeminiResponseEmpty`;不用 `usageMetadata` 判断是因为该字段为 omitempty,缺省时会误判回 OpenAI 形态\n- **effort 注入形态误判与三处死代码** - 自动托管渠道被 `RuntimeUpstreamForAutoManagedProvider` 清空 `ReasoningParamStyle` 后,`effortInjectionStyle` 一律回落 `reasoning` 对象形式,导致 Claude messages 渠道被写入 `reasoning.effort`、chat 渠道被写成对象,上游均不识别而静默丢弃 effort;改为按渠道类型推导原生形态(messages→`thinking`,chat→`reasoning_effort`)。同时修复三处「代码存在但调用链不通」的死代码:`CapabilityFloor.EffortFloor` 从未被赋值致 `filterEffortFloor` 恒直接返回,改为在 `rankEligibleModels` 内按 `ReasoningEffortConfig.PerTaskClass` 推导;`resolveRelativeBenchmarkEvidence` 的 `targetEffort` 恒传空串致 `(domain, effort)` 精确匹配与置信度折算永不执行,新增 `ResolveDomainStrengthForEffort` 并在候选展开处按变体实际档位取证据;`extractClientEffort` 协议无关全字段扫描与按渠道分支的 `ExtractClientEffortExplicit` 对跨协议字段判定相反,导致手动意图 effort 被静默屏蔽而日志记录未钳位,删除前者统一走后者。另补齐 messages 分支的 `thinking.effort` 提取(此前 `type=enabled` 只返回 `enabled` 占位而非真实档位)\n- **精确模型命中绕过 effort 决策** - `ResolveModel` 的精确/等价模型命中路径直接返回,从未调用 `resolveEffortVariants`,导致请求模型能在渠道内精确匹配时(最常见路径)手动意图锁定档位与自动展开全部失效,仅跨模型替换才走 effort 逻辑;抽出 `resolveSingleProfileEffort` 在两处分支补齐,语义与排序路径一致(意图锁定优先,否则按配置展开取最低档)\n- **effort 钳位语义与 Gemini 档位提取** - 修正 effort 钳位语义并在 failover 时重置标记;Gemini effort 提取取真实档位而非占位值,`targetByUID` 写入条件统一;`ResolvedRouteTarget` 定义与 `upstream_failover` 旧 API 保留兼容 fallback\n- **火山套餐 Key 识别与恢复** - 优先识别火山 Agent Plan 的 Kimi K3;按火山额度重置时间恢复被拉黑的 Key;`ListArkAgentPlanModel` 签名 scope 从 `ark_stg` 改回 `ark`\n- **渠道列表与诊断展示错位** - 渠道列表第二个渠道不再错显首个渠道的请求数与缓存率;渠道排序不再随 Key 拉黑变动;重新发现协议后同步刷新渠道列表;自定义渠道协议发现结果正常展示;路由过滤原因与诊断面板改用渠道名和热门模型替代 `ch_xxx` 内部 ID;Key 与健康状态同步修复\n- **驾驶舱空白** - 意图列表 nil slice 被序列化成 `null` 导致驾驶舱整页空白\n- **system 角色归一化破坏缓存边界** - providers 归一化 system 角色时保留 `cache_control` block 边界,避免打断上游 prompt 缓存\n- **billing header 域名判断忽略端口号** - 带端口的自建地址不再被误判为非官方域名,新建渠道默认值跟随 baseUrl\n- **保活验证失败无痕迹** - healthcheck 保活验证失败写入渠道日志\n- **连接延迟慢速判定误报** - 样本数不足 20 时 P95 退化为最大值导致误判慢速,新增最小样本门槛\n- **new-api 地址与图标注册** - new-api 地址自动剥离控制台路径;补齐 `mdi-github`/`mdi-plus-circle`/`mdiPause`/`timer-alert-outline`/信息提示轮廓等缺失图标注册;简化延迟告警芯片文案\n- **基准与预置数据修正** - litellm 映射补齐 26 个模型定价并去重 Kimi 会员条目;AA evidence 补 `taskCount` 修复 TS 类型校验;presetstore 支持多量纲基准原始值;区分 kimi-k3 的 256k/1m 变体上下文;清理渠道配置中过时的 grok 精确模型映射\n- **RoutingReleaseSnapshot 字段缺失** - 补全 `RolloutSeed`/`ControlPercent`,避免灰度分桶快照失真\n- **火山套餐 Key 跨套餐误探测** - 混合套餐渠道中 Agent Plan Key 不再被首地址 `/api/coding` 误打导致 auth_failed 误拉黑;火山官方套餐入口不再调用通用 `/v1/models`(套餐 Key 无法通过该接口探测),改用专用探针 + 内置 manifest 模型清单;400/404/500/网络错误不升级为认证失败\n- **渠道全部 Key 禁用时无恢复入口** - `hasOnlyDisabledChannelApiKeys` 判定(可用=0 且禁用>0)下渠道行直接显示恢复按钮,一次点击恢复全部禁用 Key,无需先暂停;active 渠道跳过冗余 `setStatus(active)`,直接 resume 并刷新\n- **非官方域名默认剔除 billing header** - `stripBillingHeader` 未显式设置时按渠道 BaseURL 推断默认值:仅当渠道所有地址均为 Anthropic 官方 API(`api.anthropic.com`)时保留计费 header,其余第三方渠道默认剔除;此前默认关闭,导致 Claude Code 每次请求变化的 `cch=` 计费 nonce 打穿第三方上游的 prompt 缓存前缀,造成缓存命中率异常下降\n\n### 重构\n\n- **移除 Fuzzy 模式开关,统一为单一 failover 策略(BREAKING)** - Fuzzy 模式早已是唯一实际使用的默认值,Normal 模式的精确状态码分类分支已无存在意义,保留两套逻辑徒增维护成本。删除 `FuzzyModeEnabled` 配置字段、`GetFuzzyModeEnabled`/`SetFuzzyModeEnabled`、`GET/PUT /api/settings/fuzzy-mode` 接口及其配置迁移逻辑;`shouldRetryWithNextKeyNormal` 与 `classifyByStatusCode` 整体删除,`shouldRetryWithNextKeyFuzzy` 重命名为 `shouldRetryWithNextKey` 成为唯一实现;`HandleAllChannelsFailed`/`HandleAllKeysFailed` 不再接收 `fuzzyMode` 参数,统一返回通用 503;各协议 `handleSuccess` 的空响应拦截(`IsClaudeResponseEmpty` 等)不再受 `fuzzyMode` 门控而无条件生效;Web 与桌面前端一并移除工具栏开关、stores 状态、API 封装与 i18n key。**行为变化**:404/405/409-417/422/423/424/426/428/431/451 与 3xx 不再走「不 failover 直接透传上游原始状态码与错误体」路径,这类错误会消耗全部 Key/渠道后以通用 503 收尾;全部渠道失败时不再透传最后一个上游错误详情(`model_not_found` 等可归一化的模型路由错误仍保留精确状态码透传)\n- **订阅中心精简** - 移除分区标题、能力标签与手动添加入口,补齐快捷接入文案;赞助商置顶、new-api 置底,GitHub Copilot 卡移到倒数第二位\n- **移除渠道菜单的试用意图入口**\n- **清理火山套餐历史分支** - 移除 `volcenginePlanModelsHost` 常量与 `endpointFor` 的 `ark_stg` 分支\n\n### 优化\n\n- **展示格式统一** - 统一所有订阅套餐余量展示格式并补全火山\"剩余\"前缀;组合展示统一为单空格分隔;渠道日志不再展示调度摘要原始串\n- **后端代码质量** - 清理 Go lint 问题,gofmt 对齐结构体字段与注释缩进\n\n### 测试\n\n- **Autopilot 分层测试补齐** - L2 SmartRouter + TraceStore 集成、L3 SQLite 生命周期、L4 HTTP 契约、L5 真实上游 smoke 与 SSE golden 回归\n- **回归与稳定性** - 快速探活多模型候选回退测试;healthcheck `per_key_test` flaky 记录断言修复\n- **基准断言同步** - 同步模型能力基准断言,且不再硬编码 registry 刷新日期与分值\n\n### 文档\n\n- **Autopilot 计划与设计** - 新增 trace 灰度实施计划、火山 AFP 感知路由计划、火山套餐 Key 保活恢复计划、渠道异步发现方案,并补充 model+effort 联合决策现状与进度\n- **配置与基准说明** - 火山 provider 模板补充 AFP 路由说明;`make help` 补充 `ARTIFICIAL_ANALYSIS_API_KEY` 用法;量化 Kimi HighSpeed 配额消耗为 3 倍;修正 `canonicalModelToPattern` 的 kimi 分支注释\n\n### 其他\n\n- **停止跟踪 upstream-check 运行时状态文件**\n- **重新生成 model registry 产物**\n\n## [v3.0.0] - 2026-07-23\n\n### 新增\n\n- **Claude Code System Header 过滤** - OpenAI/Gemini/Responses provider 自动过滤 CC billing header、identity 与 subagent 角色描述,减少上游噪音;StripBillingHeader 扩展为完整移除 billing block\n- **分层 system header 过滤接入请求流程** - Messages 入口按 channel-keyHash-model 记忆最优过滤层级,连接错误与 HTTP failover 失败时渐进升级,成功后巩固层级;仅影响 Claude 透传入口,其余 provider 保持原有过滤\n- **托管渠道模型清单重新发现** - 编辑渠道对话框为托管渠道提供重新发现按钮,触发 auto-discover 后轮询任务状态并自动刷新模型清单,支持 zh-CN/en/id 三语\n- **Kimi Code 套餐用量查询** - Kimi 自动托管凭证支持绑定本地 Web 会话令牌,查询并刷新周额度、短期频限、订阅与赠送余额;绑定、刷新与账号列表接口均不回显令牌\n- **DeepSeek 官方账号自动托管** - 快速添加同时创建 Messages、Chat 与 Responses 协议渠道,模型发现统一使用官方 `/models`,编辑渠道时可按凭证查询余额\n- **DeepSeek 峰谷成本倍率配置** - Autopilot 成本评分支持按供应商、时区和时间窗口应用倍率;内置规则延后至 2026-07-20 激活,可通过配置调整生效时间\n- **Autopilot 规范模型能力基准** - 模型注册表新增独立 benchmark 领域向量,按渠道质量证据向下折算并写入路由 trace;首批覆盖 Claude Opus 4.8、GPT-5.6 Terra 与 GPT-5.6 Sol\n- **Autopilot 手动 L3 供应商质量探测** - 新增固定 canary、每日预算与 endpoint×model 画像回写,支持 Messages、Chat、Responses、Gemini 上游协议,响应和持久化均不包含明文 Key 或模型原始输出\n- **new-api 多账号管理** - 支持为已有 new-api 订阅添加多个账号(accessToken),后端提供账号 CRUD API,渠道编辑页面可管理账号列表\n- **Autopilot 智能路由引擎(Phase 1-4 全部完成)** - SmartRouter 评分引擎(§5.3 九项公式 + §5.5 派系偏好 + §5.6 价格偏向)、确定性 TaskClassifier + 路由配置/kill switch、shadow 注入与 assist/auto 模式语义转正、EndpointAttemptPolicy(§4.6.2)+ TryUpstreamWithAllKeys、CapabilityFloor + MinQualityTier reasons、enforce model routing policy boundaries、接入模型注册表能力下界与上下文窗口硬约束、Phase 1 收尾接线、Phase 2 剩余5项集成、P1.5 迁移与回滚契约\n- **Autopilot 画像与健康诊断系统** - ProfileStore(内存缓存 + SQLite 异步持久化)、Profiler L1 被动画像推导、HealthAnalyzer L1 被动健康诊断、后台聚合 worker、健康中心只读 API 与前端视图、LocalModelRuntimeProfile 本地运行时画像与探测、LocalCandidateProvider 进入 endpoint 候选池\n- **Autopilot TrustedRoutingAdvisor 与决策追踪** - 五态状态机 + AdvisorHintEffect 路由约束解析、shadow 评估 + 决策记录 + API、画像变更事件推送与前端时间线(Phase 3A)、RoutingDecisionTrace + TraceStore + trace handlers、智能路由诊断面板与自动模型映射诊断预览\n- **Autopilot 时序与限流感知** - TimeBucketMetrics + QualityTrend 检测(§3.10)、RateLimitDiscoverer shadow(§4.5)、GroupChangeDetector 分组变更检测(§3.9)、FastDecay shadow 评分 + 批量查询 + RPM 运行态注入、接线限流信号/时序/分组变更到 Manager 与健康中心 API\n- **Autopilot L2 探测与模型映射** - L2 轻量探测 worker + 每日预算 + 探测队列、模型自动映射闭环(Phase 3B-2)、StabilityTier 晋降级滞后(Phase 3B-3)、L2 探测自愈闭环(Phase 3B-1)、自动发现完成后一致结果自动写入 SupportedModels\n- **Autopilot 订阅与用量画像** - SubscriptionProfile + 驾驶舱聚合 API + 三 store 接线、§3.2.3 订阅级能力共享 + §3.2.4 用量窗口 Phase 1 shadow、订阅中心 provider adapter(余额自动刷新)\n- **Autopilot 人工路由意图** - ManualRoutingIntent(Phase 1 shadow)、参与 SmartRouter 路由(§4.6.4 人工意图执行语义)\n- **Autopilot 真实请求画像与自适应超时** - 基于真实请求画像、TTFB 自适应响应头超时、采集端点响应头延迟画像、跨重启保留 TTFB 画像\n- **Autopilot auto 上线闸门与自动降级** - auto 上线闸门、自动降级、终态渠道降至 assist 队尾\n- **Autopilot A/B 测试** - 低比例统计抽样双发、ABTestConfig.Validate() 补齐负值/越界兜底\n- **Autopilot SLO regression 自动回滚**\n- **Autopilot 本地任务模板系统**\n- **Autopilot 用户自定义标签系统** (Phase 4 Item 1)\n- **Autopilot 成本报表** - 按用户/模型/渠道/key (Phase 4 Item 2)\n- **Autopilot 渠道推荐** (Phase 4 Item 4)\n- **Autopilot 批量渠道管理** - 导入/导出/模板 (Phase 4 Item 5)\n- **Autopilot new-api 订阅集成** - 后端 adapter + 前端\n- **Autopilot 火山方舟套餐用量查询与展示** - 未绑定 Access Key 时回退内置兜底模型清单,兜底模型清单区分 Agent Plan 与 Coding Plan\n- **Autopilot MiniMax Token Plan 用量查询** - MiMo 绑定控制台 Cookie 查询 Token Plan\n- **渠道自动探测与聚合** - 自动探测并聚合多协议渠道、在编辑页展示协议可用模型、自动识别发现协议类型与展示自动探测协议地址\n- **智谱官方渠道自动识别** - 自动识别智谱官方渠道模板,补齐桌面端渠道自动识别模板\n- **快速添加渠道 Provider 模板化** - 选 provider + 输 key 即可创建、per-key baseURL 探测与绑定\n- **多协议渠道统一展示** - 统一展示多协议渠道、按上游协议展示渠道标签、稳定多协议渠道排序\n- **渠道保活验证模块** - 新增渠道保活验证、渠道健康检查配置与持久化\n- **(Key,模型) 组合级限制与定时自动恢复** - failover 新增组合级限制,定时自动恢复\n- **内置模型清单增强** - /v1/models 补充 max_output_tokens (#266)、同步 LiteLLM 能力与 DeepSWE 证据\n- **模型能力基准数据自动更新** - benchmark 自动更新模型能力基准数据\n- **优云智算套餐余量查询** - compshare 绑定优云智算套餐余量查询、同步套餐并发限速\n- **Kimi Code 模型与按 Key 发现**\n- **火山引擎 Agent 与 Coding Plan 自动识别**\n- **自动托管账号持久化层级** - 账号级凭证接口、切换凭证差异更新、聚合旧版托管协议渠道\n- **new-api 安全分组倍率路由**\n- **远程预置更新运行时** - 预置数据集中管理 + 运行时可更新架构(Phase 1)、完成远程预置更新运行时\n- **订阅来源等级只读展示** - 来源等级由来源类型系统推导,改为只读展示\n- **火山引擎与 BytePlus 赞助**\n- **套餐类渠道 Key 行余量摘要补齐多时间窗**\n- **桌面端 autopilot 对齐** - 管理面板+健康中心+骨架(第一波)、cockpit概览+new-api订阅+试用意图(第二波)\n- **桌面端渠道自动探测与复用同步** - 同步渠道自动探测与复用、托管渠道识别模板与徽章、Codex 生图工具权限探测、TTFB 首字节 P95 列、auto 上线闸门与自动降级到桌面端健康中心\n- **Responses 音频 content block 丢弃告警** - converters: Responses 音频 content block 丢弃时输出告警日志\n- **前端 Health Center / Subscriptions / Cockpit 视图**\n- **渠道卡片健康 badge + 来源标签系统(§8.2)**\n\n### 修复\n\n- **本地部署渠道重新发现后显示\"来源未知/历史记录未保存\"** - `writeProfiles` 与 `buildEndpointInventory` 统一用 `CanonicalBaseURL` 规范化 baseURL,修复带 `/v1` 后缀时 endpointUID 不一致导致画像写入后无法被 `ListActiveByChannel` 命中的问题\n- **套餐型 Provider Key 限额重置后不自动恢复** - Kimi/MiMo/优云智算/火山用量刷新后新增 `TryRestoreDisabledKeysByUsage`,按各 Provider 用量快照的重置时间与剩余额度自动恢复因余额/限额不足被拉黑的 Key;同时修复 `syncManagedAccountsFromChannels` 仅从活跃 Key 重建凭证池、导致被拉黑托管 Key 的 Console token/AccessKey/用量快照在下次配置保存时丢失的问题\n- **Provider 多协议账号部分写入** - 首次创建与旧账号补协议改为验证后单次提交,失败不残留半成品渠道,并保留 MiMo 区域及火山套餐端点亲和性\n- **快速添加成功后对话框未关闭** - provider 自动托管创建成功后立即结束交互,模型发现继续在后台执行\n- **Autopilot 配置迁移与运行态隔离** - 旧配置块解析或升级失败时回退安全默认值,支持结构体指针叠加,并避免环境急停经管理 API 写回配置文件\n- **辅助路由协议回归** - `assist` 保留无法评分的原候选供 failover,真实路由 trace 回填实际渠道,修复模型不支持错误重试、普通 Key 的模型限制执行、relay 耗尽误限制及 Messages 响应模型字段丢失\n- **new-api 验证失败** - 修复某些 new-api fork(如 Veloera/voapi)要求 `New-API-User` header 导致的 401 错误,新增 VerifyWithFallback 两阶段验证回退逻辑\n- **套餐 Key 恢复边界条件收紧** - P1 收紧恢复条件为 AND 语义、P2 修复 Kimi/火山套餐恢复边界条件\n- **system header 过滤稳定性修复** - 修复 panic、过期误判与误升级问题、字符串形式 system 未过滤 CC header 的问题\n- **Autopilot 替代模型选择优化** - 按任务难度选择替代模型、优先选择更强同档模型、使用渠道模型倍率排序、按能力下限优化排序、避免常规任务过度升级模型、修正多模态模型能力路由\n- **Autopilot 模型发现与画像修复** - 记录模型发现来源与时间、自适应模型解析修正、endpoint metrics identity 对齐、profile model support decisions 遵守、当前有效画像清单对账\n- **Autopilot 路由约束对齐** - scheduler diagnose 与 request profile 对齐、shadow 与自动路由约束对齐、dry-run 与自动路由决策对齐、诊断画像与路由追踪统一\n- **Autopilot 配置迁移与隔离修复** - 自动升级旧版 Autopilot 配置、配置迁移隔离完善、环境急停经管理 API 写回配置文件隔离\n- **火山方舟 Agent Plan 修复** - 探针与协议入口区分、Key 端点验证修复、套餐 Key 验证请求特征对齐、Coding Plan 用量刷新修复、套餐模型发现签名修复\n- **渠道展示与编辑修复** - 托管渠道真实模型范围展示、额度禁用状态与用量展示修正、渠道行展示与官方渠道命名/官网编辑优化、渠道添加与官方渠道编辑流程简化\n- **自动托管渠道编辑精简** - 多轮精简自动托管渠道编辑项、收敛编辑项\n- **Autopilot 端到端流程修复** - auto-add/auto-status 端到端流程、辅助路由协议回归、终态渠道降至 assist 队尾\n- **MiMo 自动发现修复** - 自动发现状态修复、自动发现失败修复、自动添加多协议渠道\n- **桌面端同步修复** - 额度禁用状态与 Key 计数、baseUrl 清理路径、自动渠道命名简化\n- **前端图标注册修复** - mdi-check-all、DeepSeek 余额图标、mdi-connection、ApiKeyManagementSection 缺失 mdi 图标、官方渠道标识图标\n- **前端 TypeScript 编译错误修复**\n- **Compshare system roles 规范化** - messages: normalize Compshare system roles\n- **LLM 统一渠道视图置顶/置底排序修复**\n- **模型定价同步与未配置成本标记**\n- **重新发现轮询识别 failed 状态并展示错误**\n- **拉取渠道模型列表时使用 key 级绑定 baseUrl**\n- **模型能力基准多来源更新与图表生成修复**\n- **熔断引入模型多样性门槛与渠道组合失败熔断**\n- **自动托管渠道官网地址持久化**\n- **快速添加渠道名称预览修复**\n- **最终上游模型日志记录** - logging: record final upstream model\n- **内部元数据泄漏到续轮请求阻止** - responses: 阻止内部元数据泄漏到续轮请求\n- **快速添加前去重 API Key**\n- **渠道自动命名简化与仪表盘轮询合并**\n- **容器后端验证的前端嵌入修复**\n- **baseUrl 清理剔除 /api-keys 创建页路径**\n- **disabled managed credentials 跳过** - routing: skip disabled managed credentials\n- **Claude Code system 指纹与客户端探针特征对齐**\n- **小屏协议地址布局优化与协议名称换行避免**\n- **添加弹窗正文滚动与 usage 控制台路径识别**\n- **Autopilot 按真实协议创建自定义渠道**\n- **Autopilot DeepSeek 官方账号托管完善**\n- **逻辑渠道多协议日志汇总**\n- **按客户端协议持久化画像分类与发现完成后立即持久化**\n- **绑定模型发现结果保留**\n- **官方 provider 凭证池复用与自动托管官方渠道身份标明**\n- **按 endpoint binding 隔离模型调度** - routing: 按 endpoint binding 隔离模型调度\n- **旧版托管协议渠道聚合**\n- **官方渠道改用智能调度**\n- **快速添加成功后关闭弹窗**\n- **content_part 流式事件识别** - responses: 识别 content_part 流式事件\n- **schema 迁移幂等自愈 advisor_decisions.reason 列**\n- **scoreEndpointForKey EndpointUID 与 handlers 层不一致修复**\n- **RateLimitApplier kill switch 测试闭包捕获值拷贝问题**\n- **Responses input item additional_tools 剥离**\n- **未知健康状态徽标颜色调整**\n- **快速添加上游类型选择移除**\n- **黑名单密钥恢复状态提示**\n\n### 重构\n\n- **订阅中心重构为 Provider 卡片式快捷接入面板** - 移除订阅列表表格展示,改为双栏布局:左侧 Provider 卡片(GitHub Copilot/new-api/手动添加),右侧详情表单;订阅中心只负责接入新订阅\n- **Agent skills 迁移至 .claude/skills** - 通过 symlink 将 agent skills 迁移到 .claude/skills 目录\n- **协议模型可用性 diff 视图重新设计** - frontend: 重新设计协议模型可用性差异视图\n- **Kimi 用量展示简化** - frontend: 简化 Kimi 用量展示以匹配官方 CLI\n\n### 优化\n\n- **路由性能优化** - config: clone only selected upstream;routing: cache request channel snapshots、prefilter incapable managed channels、prefilter channels without usable keys\n\n### 文档\n\n- **Autopilot 设计文档** - 新增同订阅多 Key 能力共享/配额负载均衡(§3.2.3)与用量窗口(§3.2.4)设计、任务域优势矩阵(§5.7)与思考等级差异(§5.8)设计、内置模型清单设计(§4.2.1)、new-api 集成与订阅 Provider Adapter 框架、Phase 3A/3B/4 全部完成状态更新\n- **桌面端文档** - 补充后端发现与端口附着约定\n- **Codex 上游协议变更评估** - 追加 Codex rust-v0.145.0 上游协议变更评估、新增 gpt-5.6 适配与 codex imagegen 上游文生图待办\n- **SignPath 代码签名致谢** - 文档站首页与 README 添加 SignPath 代码签名致谢,logo 放大并链接到官网\n- **Star History 图表更新** - 调整为 Markdown 图片并更新 token\n- **火山引擎推广链接** - README 为火山引擎注册即领 2500 万 Tokens 添加推广链接\n\n### 其他\n\n- **Windows 签名切换至正式证书策略** - ci: 切换 Windows 签名至正式证书策略\n- **Serena 项目配置更新**\n- **Autopilot 日志降噪** - 降低画像落盘成功日志噪声、移除画像推导冗余日志\n- **Codex 0.143.0 upstream check 记录**\n- **Apple Container 隔离验证** - 添加 Apple Container 隔离验证环境\n- **下载按钮改为两行网格布局** - style: 下载按钮改为两行网格布局\n\n## [v2.9.37] - 2026-07-07\n\n### 新增\n\n- **渠道选择 Trace 全链路诊断** - scheduler: 新增 channel selection trace,覆盖候选过滤、活跃模型过滤、选择失败原因,支持 `/v1/models/diagnose` 干跑视图与流式日志摘要\n- **响应续思折叠** - responses: 上游截断的 reasoning continuation 在代理层自动折叠,避免客户端收到不完整思考链\n- **渠道配置发现增强** - discovery: 增强渠道配置发现能力,改进推荐算法\n- **瞬态渠道发现** - discovery: 新增瞬态渠道发现机制,支持快速探测上游可用性\n- **断路器温和策略改为新默认** - circuit-breaker: 调整断路器策略预设,温和策略取代激进策略作为新默认值\n- **Agnes 模型描述注册** - registry: 为 Agnes 系列模型补充描述信息\n- **store-update 完成后自动打开下载目录** - skill: store-update 下载完成后自动在文件管理器中打开目标目录\n\n### 修复\n\n- **discovery 工具调用探测协议选择错误** - discovery: messages 渠道 serviceType 为 responses/chat/gemini 时,工具调用能力探测始终用 Claude 格式导致假阴性;修复为按 serviceType 自动选择 responses/chat/gemini 对应格式\n- **discovery 不再生成 supportedModels 通配白名单** - discovery: 发现流程自动写入 \\*alias\\* 通配符模式(\\*opus\\*、\\*gpt\\* 等)到 supportedModels,实际上渠道无需此限制;删除生成逻辑,apply 时始终清空旧值\n- **discovery 协议探测失败时模型映射为空** - discovery: 用户显式选定 channelKind 但该协议探测失败时,buildDiscoveryMappingRecommendation 返回空映射;降级到任意成功协议的模型重新构建别名映射\n- **thinking probe 误覆写 passbackReasoningContent** - compat: 历史 thinking block 探测返回 400/422 时错误地将 passbackReasoningContent 置为 false;此标志由独立 thinking 预算探测决定,不受历史块探测影响\n- **协议延迟统计含超时模型** - discovery: runDiscoveryProtocolProbe 对所有模型(含超时)的延迟做均值,导致有效延迟数据被拉高;改为仅统计成功模型均值\n- **gemini 渠道 reasoningMapping 暂不生成** - discovery: Gemini handler 目前未消费 reasoningMapping,写入配置只产生无效噪声;明确不为 gemini 渠道生成该字段,待 Gemini thinking 路径支持后再启用\n- **compat evidence 迭代顺序不稳定** - discovery: map 迭代顺序不确定导致证据列表每次不同;改为先排序 key 再遍历\n- **熔断器未将 no_available_account 计为过载** - failover: 上游返回 no_available_account 未触发熔断,新增 FailureClassOverloaded 分类\n- **serviceType 派生协议回退优先** - discovery: 当用户未显式指定 channelKind 时,优先使用 serviceType 派生的协议作为回退,而非仅依赖探测成功模型\n- **回退成功模型未传播到后续探测** - discovery: 首次协议探测成功得到的模型在后续探测中被忽略,导致其他协议探测缺少候选;改为将回退成功模型注入所有后续探测\n- **目标协议失败时未从回退协议构建映射** - discovery: 目标协议探测完全失败时 buildDiscoveryMappingRecommendation 返回空;改为从任意成功协议的模型重新构建别名映射\n- **发现流程白名单生成污染 supportedModels** - discovery: 自动写入通配符模式到 supportedModels 实际上限制了渠道可见模型;删除生成逻辑,apply 时始终清空旧值\n- **messages 渠道工具调用探测格式错误** - discovery: 工具调用能力探测始终用 Claude 格式;修复为按 serviceType 自动选择 responses/chat/gemini 对应格式\n- **显式渠道协议推荐被覆盖** - discovery: 用户明确指定 channelKind 时协议推荐仍被探测结果覆盖;改为优先保留用户选择\n- **malformed 响应未触发重试** - failover: 上游返回 malformed 响应体时未触发重试机制;新增重试逻辑\n- **Vue SFC 模块类型声明缺失** - desktop: 前端 typecheck 无法识别 .vue 文件模块声明;补充 env.d.ts 中的 SFC 模块声明\n- **桌面端开发模式未加载嵌入前端资源** - desktop: Wails dev 模式未正确加载 embed.FS 中的前端资源\n- **Wails 开发服务器日志过于冗杂** - desktop: 抑制 Wails dev server 信息级别日志输出\n- **.vue 导入 TS2307 类型错误** - frontend: tsconfig 未包含 env.d.ts 导致 .vue 文件导入报 TS2307\n- **模型列表未暴露上下文窗口** - models: /v1/models 返回结果未包含 context window 信息\n- **模型聚合等待窗口过短** - discovery: 多上游模型聚合的等待窗口不够长,部分慢响应渠道被遗漏\n- **discovery 重复创建 channel models fetcher** - discovery: 每次发现流程重新创建 fetcher 实例,改为复用已有实例\n- **上游临时过载未触发熔断** - backend: 上游返回 503/429 等临时过载状态码时未正确触发断路器\n- **未配置模型时 /v1/models 返回空** - models: 当模型未在上游注册时,回退到渠道配置的模型列表\n- **discovery 通道推荐 UI 对齐** - discovery: 通道推荐 UI 展示对齐设计稿\n- **上下文能力菜单被遮挡** - frontend: 上下文能力下拉菜单被其他元素遮挡\n- **渠道 URL 包含 profile wallet 路径** - frontend: 前端展示渠道 URL 时未剥离 profile wallet 路径前缀\n- **渠道 URL 包含 admin 路径** - frontend: 前端展示渠道 URL 时未剥离 /admin 路径前缀\n- **桌面端上下文模型下拉层级错误** - desktop: 上下文模型下拉菜单被高级选项遮挡,提升层级\n\n### 优化\n\n- **对齐 model registry map keys 格式** - style: 统一模型注册表 map keys 对齐方式,移除测试文件多余空行\n\n### 文档\n\n- **Channel Autopilot 设计文档** - docs: 新增 Channel Autopilot 自动路由设计文档\n- **Channel Autopilot 端点级粒度设计** - docs: 细化 Channel Autopilot 到端点级别的路由粒度设计\n- **Channel Autopilot 时间质量感知** - docs: 为 Channel Autopilot 设计补充时间维度质量感知机制\n- **Channel Autopilot 手动路由意图** - docs: 新增手动路由意图设计,支持用户显式指定路由偏好\n- **Channel Autopilot 成本感知路由** - docs: 新增成本感知路由设计,在质量与成本间自动权衡\n- **Channel Autopilot 原生 image/vector 路由** - docs: 将原生 image 与 vector 渠道纳入自动路由范围\n- **可信本地路由顾问** - docs: 新增可信本地路由顾问设计,基于历史数据提供路由建议\n- **用户价格偏向 (Cost Preference) 设计** - docs: 新增用户价格偏向设计,支持按成本偏好调整路由权重\n- **上游供应商质量差异设计 (ProviderQualityScore)** - docs: 补充上游供应商质量差异评分设计\n- **模型派系偏好 (Model Family Preference) 设计** - docs: 新增模型派系偏好设计,支持用户指定偏好的模型家族\n- **ModelFamily 枚举扩展至 17 个派系** - docs: 模型派系枚举扩展至 17 个,来自模型注册表\n- **Channel Autopilot 验收契约** - docs: 新增 Channel Autopilot 验收测试契约文档\n\n### 改进\n\n- **保留用户显式选定的渠道协议推荐** - discovery: 用户明确指定 channelKind 时优先保留别名,不受协议探测结果覆盖\n\n## [v2.9.36] - 2026-07-05\n\n### 新增\n\n- **集成 vectors Embedding 兼容性面板到桌面端渠道编辑流程** - desktop: 集成 vectors Embedding 兼容性面板到渠道编辑流程\n- **新增 vectors Embedding 兼容性配置面板组件** - desktop: 新增 vectors Embedding 兼容性配置面板组件\n- **移植 embedding 兼容性类型/序列化/i18n 到桌面端** - desktop: 移植 embedding 兼容性类型、序列化与 i18n 到桌面端\n- **OpenAI 响应思考标签提取为原生 reasoning 字段** - provider: OpenAI 响应思考标签提取为原生 reasoning 字段\n\n### 修复\n\n- **对齐桌面端 capability 测试反馈** - desktop: 对齐桌面端 capability 测试反馈\n- **使用结构化对话轮次** - cockpit: 使用结构化对话轮次替代纯文本\n- **embedding 兼容性配置边界问题** - vectors: 修复 embedding 兼容性配置边界问题\n- **剥离 embedding 请求体中的 stream 字段** - vectors: 剥离 embedding 请求体中的 stream 字段避免上游 SSE 误触发\n\n### 优化\n\n- **提取 buildEndpointURL 正则为 package-level var** - vectors: 提取 buildEndpointURL 正则为 package-level var 避免重复编译\n\n### 其他\n\n- **补齐 embedding capability 与 handler 边界测试** - test: 补齐 embedding capability 与 handler 边界测试\n\n## [v2.9.35] - 2026-07-04\n\n### 新增\n\n- **展开态当前渠道点击支持固定与取消固定切换** - cockpit: 展开态当前渠道点击支持固定与取消固定切换\n- **快捷渠道覆盖支持固定与取消固定** - cockpit: 快捷渠道覆盖支持固定与取消固定\n- **发布成功后自动调用 store-update** - github-release: 发布成功后自动调用 store-update\n\n### 修复\n\n- **向量化渠道兼容性配置边界问题** - vectors: 修复桌面端 Embedding 兼容性面板未指定 normalized 时崩溃、Vectors 渠道重名更新、非法 embeddingCapabilities 保存与非法 input 本地校验缺口\n- **拦截 Claude Code 模型切换探针避免空响应误判** - messages: 拦截 Claude Code 模型切换探针避免空响应误判\n- **修复 copilot 渠道 /v1/models 代理返回 404** - models: 修复 copilot 渠道 /v1/models 代理返回 404\n- **修正 gitignore 规则并添加 git 代理自动检测** - store-update: 修正 gitignore 规则并添加 git 代理自动检测\n\n### 其他\n\n- **同步 skill 更新到 .agents/skills/** - chore: 同步 skill 更新到 .agents/skills/\n\n## [v2.9.34] - 2026-07-04\n\n### 新增\n\n- **注册 mdiCash 图标** - ui: 注册 mdiCash 图标,补充前端图标资源\n- **5xx + Retry-After 也触发限速 cooldown** - ratelimit: 上游返回 5xx 或 Retry-After 头时同样触发限速 cooldown\n- **上游临时过载检测与渠道冷却机制** - failover: 新增上游临时过载检测与渠道冷却机制,提升故障转移鲁棒性\n\n### 修复\n\n- **单模型渠道 CostUSD 不显示** - metrics: 修复单模型渠道 CostUSD 不显示的问题\n- **限速窗口单位语义修正** - ratelimit: 修正限速窗口单位语义,字段值直接以秒为单位\n\n### 其他\n\n- **Serena 本地记忆目录加入忽略列表** - chore: 将 Serena 本地记忆目录加入 gitignore\n\n## [v2.9.33] - 2026-07-04\n\n### 修复\n\n- **DeepSeek context_management 不兼容导致 400** - providers: 过滤 DeepSeek Claude 兼容端点不支持的 `context_management.clear_thinking_20251015` 编辑和 `context-management-2025-06-27` beta header,修复 Claude Code 首轮/recap 请求的 thinking 回传 400\n\n## [v2.9.32] - 2026-07-03\n\n### 修复\n\n- **ChannelManager canDeleteChannel 引用错误** - desktop: 修复 ChannelManager canDeleteChannel 引用错误\n\n## [v2.9.31] - 2026-07-03\n\n### 新增\n\n- **DeepSeek tool_use passback 双重降级缓存** - thinking: DeepSeek tool_use passback 双重降级缓存\n\n### 修复\n\n- **opaque encrypted_reasoning 占位保障 DeepSeek thinking passback** - responses: opaque encrypted_reasoning 占位保障 DeepSeek thinking passback\n\n## [v2.9.30] - 2026-07-03\n\n### 新增\n\n- **原生 WebSocket v2 透传** - responses: 支持原生 WebSocket v2 透传\n- **流式 session 回写与 incomplete 合成** - responses: 流式路径 session 回写并在 post-commit stall 时合成 incomplete\n- **Responses reasoning encrypted_content 缓存** - thinkingcache: 扩展存储 Responses reasoning encrypted_content\n- **分层保真 compact** - compact: 增加分层保真 compact 与 function_call_output 摘要\n\n### 修复\n\n- **快速创建渠道数据源提交** - desktop: 统一快速创建渠道时提交数据源\n- **视觉回退上下文校验** - failover: 校验 vision fallback context,避免无效视觉回退上下文继续传播\n- **agent skills 路径与 Store MSIX tag 下载** - skill: 修复 `.agents/skills` 内 skill 文档误指向 `.claude/skills` 的路径,并确保 store-update 脚本的 `--tag` 会传递给 `gh release download`\n\n### 其他\n\n- **desktop-store-update 技能清理** - skill: 删除 desktop-store-update 技能并更新工具配置\n- **agent skills 同步** - skill: 同步 agent skills 文档、脚本和 upstream-check 状态\n\n## [v2.9.29] - 2026-07-03\n\n### 新增\n\n- **token 成本(USD) 统计** - metrics: 新增 token 成本(USD) 统计\n- **loadShed reaper 与 cooldown 软跳过改进** - scheduler: loadShed reaper 自动推进到期状态、cooldown 软跳过与 scoped limiter 清理改进\n- **preflight 空响应详情诊断与敏感信息脱敏** - stream: preflight 空响应详情诊断与敏感信息脱敏\n\n### 修复\n\n- **MiniMax M2 系列上下文与输出元数据** - model-registry: 修正 MiniMax M2 系列上下文与输出元数据\n- **火山方舟 quota exceeded 识别与临时限流误判** - failover: 识别火山方舟 quota exceeded 并修复临时 token-per-minute 限流误判\n- **桌面渠道模型映射下拉框显示** - desktop: 修复渠道模型映射下拉框显示并清理调试日志\n\n### 文档\n\n- **Microsoft Store submission API 凭据获取说明** - skill: 补充 Microsoft Store submission API 凭据获取说明\n\n### 其他\n\n- **desktop-store-update 技能** - skill: 新增 desktop-store-update 技能\n\n## [v2.9.28] - 2026-07-02\n\n### 新增\n\n- **BIND_HOST 环境变量支持** - server: 新增 BIND_HOST 环境变量以绑定指定网络接口\n- **Claude thinking 回填缓存** - thinkingcache: 新增 Claude thinking 回填缓存模块\n- **子代理精确识别** - agent: 通过 X-Claude-Code-Agent-Id 精确识别子代理\n- **统一配置管理与 CLI 选项** - thinkingcache: 统一 thinking 缓存配置管理与 CLI 选项\n- **DeepSeek Claude thinking 缓存诊断** - compat-diagnose: DeepSeek Claude 渠道启用 thinking 缓存诊断\n- **DeepSeek Claude 预设 thinking 缓存模式** - preset: DeepSeek Claude 预设切换到 thinking 缓存模式\n\n### 修复\n\n- **stop_reason 仅事件误判** - stream: stop_reason 仅事件不再误判为语义内容\n\n### 重构\n\n- **override 日志增强** - scheduler: 增强 override 日志输出\n\n## [v2.9.27] - 2026-07-02\n\n### 新增\n\n- **渠道预设 maxTokens 支持** - desktop/preset: 渠道预设新增 maxTokens 字段并支持桌面端配置\n- **渠道 preset authHeader 字段** - preset: 渠道 preset 新增 authHeader 字段\n- **thinking-only 响应识别** - stream: preflight 支持 thinking-only 响应识别为有效内容\n- **订阅 Tab 优化与 preset 清理** - desktop: 订阅 Tab 优化与 preset channel_types 清理\n- **LongCat-2.0 与 Step 3.7 Flash 模型注册** - models: 新增 LongCat-2.0 与 Step 3.7 Flash 内置模型注册\n\n### 修复\n\n- **轮询立即触发并统一活跃 job 收集** - capability: 修复轮询调度延迟与活跃 job 收集逻辑\n- **兼容上游裸 JSON 行 SSE 流** - claude: 兼容上游裸 JSON 行 SSE 流\n\n### 重构\n\n- **ENV 字段移至服务器配置组** - desktop: 将 ENV 字段移至服务器配置组 PORT 之后\n- **SSE 流式规范化重构** - claude: 重构 SSE 流式规范化并补齐响应必需字段\n- **openai-code-v2 channel_types 移至测试层** - capability: 将 openai-code-v2 channel_types 移至测试层\n\n### 其他\n\n- **Windows MSIX 打包图标资源** - build: 添加 MSIX 打包所需多尺寸图标资源\n\n## [v2.9.26] - 2026-07-01\n\n### 新增\n\n- **Copilot 代理支持** - copilot: OAuth 流程支持代理地址并优化超时处理,支持通过代理进行 Copilot token 交换,新增 Copilot 渠道默认 BaseURL 自动回填\n- **Copilot 订阅中心** - desktop: 新增订阅中心页面,将 Copilot OAuth 从渠道创建移至独立 Tab,支持多账号 key 池管理、多渠道目标与诊断结果展示\n- **Copilot 预设与前端适配** - desktop/preset: 新增 GitHub Copilot 预设并支持 proxyUrl 字段;web/frontend: Copilot 渠道支持代理地址输入,channelPayload 支持 Copilot 默认 BaseURL\n- **订阅 Tab 账号展示增强** - desktop: 增强订阅 Tab 账号展示与 Copilot 模型获取,支持国际化\n- **Claude Sonnet 5 注册** - models: 注册 Claude Sonnet 5 模型\n- **模型元数据增强** - models: 丰富模型元数据,支持 Anthropic 家族分类,优化模型发现机制并添加缓存降级与 keypool 集成\n- **Claude Desktop 连接探测** - messages: 内置 Claude Desktop 连接探测响应\n- **本地 HTTPS 自签名证书** - tls: 支持本地 HTTPS 自签名证书\n- **Codex 原生 WebSocket 协议** - responses: 支持 Codex 原生 WebSocket 协议\n- **商汤日日新接入** - sensenova: 新增商汤日日新 LLM 平台接入\n- **讯飞 Coding Plan 迁移** - preset: 讯飞 Coding Plan 迁移并支持 serviceType 配置\n- **MiMo 计划选择器** - ui: 为 Codex 和 OpenCode 添加 MiMo 计划选择器\n- **超时控件优化** - ui: 将超时控件暴露为秒级显示\n- **macOS 应用内更新** - desktop: 新增 macOS 应用内更新打包支持\n- **会话预览** - ui: 添加可展开的会话预览功能\n- **渠道 Key 复用显示** - desktop: 渠道 Key 复用时显示当前值\n- **image_generation 探测** - diagnose: 探测上游 image_generation tool 支持\n- **渠道编辑交互优化** - channel-editor: 优化渠道编辑交互体验\n\n### 修复\n\n- **copilot serviceType 映射** - capability: 修复 copilot serviceType 映射到 responses 协议\n- **Copilot 订阅 token 复用** - desktop: 修复 Copilot 订阅保存 token 复用与渠道状态管理\n- **copilot BaseURL 清空** - desktop: 修复 copilot 渠道 BaseURL 因空版本前缀被错误清空的问题\n- **normalizeMetadataUserId 协议限制** - config: 将 normalizeMetadataUserId 限定为仅 Messages 协议生效\n- **TLS 证书路径提示** - tls: 证书路径为相对路径时增加绝对路径提示\n- **responses 渠道 normalizeMetadataUserId** - channel-editor: responses 渠道默认关闭 normalizeMetadataUserId\n- **Copilot OAuth 超时上下文** - copilot: 补充 OAuth 请求超时与错误上下文\n- **模型点击排队** - capability-tests: 修复桌面端模型点击排队问题\n- **provider key 资产匹配** - channel: currentAsset 按 planID 精确匹配 provider key 资产\n- **keysByProvider 去重** - preset: keysByProvider 去重保留首个 asset\n- **plan-scoped key 存储** - configservice: plan-scoped key 存储不覆盖 provider channel key\n- **Responses URL 重复拼接** - api: Responses URL 末尾已含 endpoint 时避免重复拼接\n- **MiMo reasoning effort** - api: 规范化 MiMo Responses reasoning effort 上限\n- **Codex tool_search 剥离** - api: 剥离 Codex tool_search 工具调用\n- **compaction 流** - api: 允许 compaction 仅含 usage 的流式响应\n- **MiMo token plans** - desktop: 允许 MiMo token plans 用于 responses 协议\n- **brew deprecated syntax** - brew: 替换已废弃的 `depends_on` 字符串语法\n- **模型能力同步延迟** - ui: 编辑期间延迟模型能力同步\n- **thinking reasoning 参数** - api: 规范化 thinking reasoning 参数\n- **MiMo thinking style** - channel: MiMo 预设使用 thinking style\n- **渠道高级选项布局** - ui: 改善渠道高级选项布局\n\n### 重构\n\n- **ResolveTokenWithProxy** - handlers: 全量替换 Copilot ResolveToken 为 ResolveTokenWithProxy\n- **渠道编辑器 composables 拆分** - channel-editor: 接入 useEditChannelModal 主 composable,提取禁用 Key 恢复/模型获取/Payload 构建、表单选项/派生计算/预设/工具函数、UI 辅助 composables\n- **应用 composables 提取** - app: 提取 useToasts + useAppController composable,提取 savedProviderKeyForPlan 按 planID 匹配资产 key\n- **ChannelEditDialog composables** - desktop: 提取 ChannelEditDialog composables\n- **configservice 瘦身** - configservice: service.go 瘦身为纯调度层,提取 Provider Key 管理、预览/试运行、OpenCode 配置、Codex 配置与会话迁移、路径解析与 Claude 配置、file/toml/jsonc 格式辅助函数\n- **渠道自定义参数面板分离** - ui: 将渠道自定义参数面板拆分为独立组件\n\n### 文档\n\n- 补充桌面端与环境变量文档\n- 清理已完成的上游版本评估条目\n- 添加 Claude Code 2.1.196 上游版本审查\n- 澄清快速模式使用成本说明\n\n### 测试\n\n- 覆盖 Codex tool_search 剥离测试\n\n## [v2.9.25] - 2026-06-29\n\n### 新增\n\n- **Copilot 授权码一键复制** - channel: 为 Copilot 授权码添加一键复制操作,便于完成 OAuth 配置\n\n### 修复\n\n- **提示浮层不被容器裁剪** - ui: 保持 tooltip 显示在被裁剪容器之上\n- **桌面端熔断器弹窗布局** - desktop: 保持熔断器弹窗布局在正常文档流中,避免内容溢出错位\n- **移除全局历史图片限制控件** - ui: 移除全局 historical image limit 控件,避免与渠道级配置混淆\n- **历史图片限制改为渠道级配置** - config: 将 historical image limit 调整为渠道级配置\n\n### 重构\n\n- **抽取 Copilot OAuth 路径常量** - channel: 提取 Copilot OAuth 路径常量并修复授权码清理逻辑\n\n### 文档\n\n- **澄清渠道级图片限制变更** - changelog: 澄清历史图片限制已改为渠道级配置\n\n## [v2.9.24] - 2026-06-29\n\n### 新增\n\n- **MiMo 预设思考强度配置** - channel: 将 MiMo 预设的思考强度配置学习到一键预设\n\n### 修复\n\n- **Volc Ark coding 端点预设** - desktop: 修正 Volc Ark coding 端点预设配置\n- **渠道日志与 Key 错误展示** - desktop: 改善渠道日志和 key 错误信息的显示\n- **认证区域置于重定向前** - channel: 将认证配置区域放在重定向区域之前\n- **Copilot OAuth 外部链接** - desktop: 修复 Copilot OAuth 链接在桌面端无法打开的问题\n\n### 其他\n\n- **延长桌面端 macOS 公证超时** - release: 延长桌面端 macOS 公证任务超时时间\n\n## [v2.9.23] - 2026-06-29\n\n### 修复\n\n- **剥离不支持的 Claude thinking blocks** - responses: strip unsupported Claude thinking blocks\n- **优化 Claude 兼容性诊断** - compat: refine Claude compatibility diagnosis\n- **刷新熔断窗口近期历史** - metrics: refresh breaker windows from recent history\n\n### 测试\n\n- **稳定 thinking 兼容性覆盖** - responses: stabilize thinking compatibility coverage\n\n### 其他\n\n- **延长 macOS 公证超时时间** - release: extend macOS notarization timeout\n\n## [v2.9.21] - 2026-06-28\n\n### 新增\n\n- **渠道日志复制操作** - channel: 为渠道日志添加复制操作,便于快速复制日志内容排查问题\n- **GPT-5.6 能力探测与渠道选项** - capability: 接入 GPT-5.6 能力探测与渠道配置选项\n- **GPT-5.6 Amazon Bedrock 模型定义** - model-registry: 新增 GPT-5.6 Amazon Bedrock 模型定义\n\n### 修复\n\n- **保留 Claude 主 API Key** - desktop: 修复桌面端配置同步时 Claude 主 API key 被覆盖或丢失的问题\n- **Base URL 哈希诊断校验** - 修复 base url hash 诊断校验逻辑,提升问题定位准确性\n- **桌面端流式超时默认值** - desktop: 对齐并保留 stream timeout 默认值\n- **渠道菜单浮层稳定性** - frontend: 稳定渠道操作菜单 overlay 展示,避免交互抖动\n- **Copilot OAuth 国际化文案** - desktop: 补齐 Copilot OAuth 桌面端多语言文案\n- **输出 token 按模型限制裁剪** - routing: 根据模型能力限制下调超限输出 token,避免上游参数错误\n- **能力探测携带必需 thinking 参数** - capability: 强制思考模型探测时自动携带 thinking/reasoning 参数,避免能力误判\n- **Kimi K2.7 输出限制修正** - model-registry: 将 Kimi-K2.7-Code 最大输出与推荐输出修正为 32768\n\n## [v2.9.20] - 2026-06-27\n\n### 新增\n\n- **展示 Claude Code recap** - cockpit: 展示 Claude Code recap\n- **记录并展示 Claude Code recap** - cockpit: 记录并展示 Claude Code recap\n\n### 其他\n\n- **限制 macOS 公证任务时长** - release: 限制 macOS 公证任务时长\n- **并行化 macOS 签名公证流程** - release: 并行化 macOS 签名公证流程\n\n## [v2.9.19] - 2026-06-27\n\n### 新增\n\n- **为对话驾驶舱补充悬浮提示** - ui: 为对话驾驶舱补充悬浮提示\n\n### 修复\n\n- **strip Codex response metadata passthrough** - strip Codex response metadata passthrough\n- **处理 Responses 流式错误事件** - responses: 处理 Responses 流式错误事件\n- **补齐印尼语缺失翻译** - i18n: 补齐印尼语缺失翻译\n- **跳过 Claude 空输出重试标题** - backend: 跳过 Claude 空输出重试标题\n- **remove channel logs refresh button** - desktop: remove channel logs refresh button\n\n### 重构\n\n- **抽取 Responses WebSocket 回退处理器** - backend: 抽取 Responses WebSocket 回退处理器\n\n### 其他\n\n- **配置 macOS 签名公证** - desktop: 配置 macOS 签名公证\n\n## [v2.9.18] - 2026-06-26\n\n### 新增\n\n- **捕获 WebView 前端运行时错误** - desktop: 捕获 WebView 前端运行时错误\n- **支持前端错误写入桌面日志** - desktop: 支持前端错误写入桌面日志\n- **同步渠道预设兼容选项** - channel: 同步渠道预设兼容选项\n- **优化兼容诊断默认建议** - channel: 优化兼容诊断默认建议\n- **展示主对话最新消息** - frontend: 展示主对话最新消息\n- **更新 MiMo V2.5 模型能力** - model-registry: 更新 MiMo V2.5 模型能力\n- **添加兼容性诊断入口** - channel: 添加兼容性诊断入口\n- **添加兼容性诊断** - channel: 添加兼容性诊断\n- **优化驾驶舱展开对话卡片展示** - ui: 优化驾驶舱展开对话卡片展示\n- **添加驾驶舱关联对话导航** - ui: 添加驾驶舱关联对话导航\n- **GitHub Copilot 共享认证支持与安全加固** - GitHub Copilot 共享认证支持与安全加固\n- **支持 Chat/Messages/Gemini 入口转换到 Copilot** - copilot: 支持 Chat/Messages/Gemini 入口转换到 Copilot\n- **优化添加渠道引导与文档** - copilot: 优化添加渠道引导与文档\n- **优化前端诊断结果展示** - copilot: 优化前端诊断结果展示\n- **增加 OAuth 重试/取消与渠道诊断** - copilot: 增加 OAuth 重试/取消与渠道诊断\n- **补齐 ping、错误分类与 OAuth 状态展示** - copilot: 补齐 ping、错误分类与 OAuth 状态展示\n- **支持 GitHub Copilot OAuth 与 Responses 上游接入** - copilot: 支持 GitHub Copilot OAuth 与 Responses 上游接入\n- **ConversationCard 展示子代理列表与状态摘要** - web: ConversationCard 展示子代理列表与状态摘要\n- **支持流式会话状态追踪** - conversation: 支持流式会话状态追踪\n\n### 修复\n\n- **区分主对话与子代理覆盖状态** - ui: 区分主对话与子代理覆盖状态\n- **支持仅子代理渠道覆盖** - backend: 支持仅子代理渠道覆盖\n- **覆盖 Gemini 内联图并加固图片 token 估算** - utils: 覆盖 Gemini 内联图并加固图片 token 估算\n- **按真实尺寸估算内联 base64 图片的 token** - utils: 按真实尺寸估算内联 base64 图片的 token\n- **跳过空 tool_calls 避免 text block 被拆分** - providers: 跳过空 tool_calls 避免 text block 被拆分\n- **移除折叠态子代理摘要** - frontend: 移除折叠态子代理摘要\n- **按展开状态置顶对话卡片** - ui: 按展开状态置顶对话卡片\n- **修正对话卡片展开展示** - ui: 修正对话卡片展开展示\n- **跳过 Claude Command 文档标题回退** - messages: 跳过 Claude Command 文档标题回退\n- **优化对话卡片展开展示** - ui: 优化对话卡片展开展示\n- **同步单轮主对话预览截断** - desktop: 同步单轮主对话预览截断\n- **优化单轮主对话预览截断** - frontend: 优化单轮主对话预览截断\n- **拆分驾驶舱主对话轮次展示** - frontend: 拆分驾驶舱主对话轮次展示\n- **提供全局提示组件上下文** - desktop: 提供全局提示组件上下文\n- **优化驾驶舱对话 ID 展示** - ui: 优化驾驶舱对话 ID 展示\n- **优化驾驶舱窄屏工具栏布局** - desktop: 优化驾驶舱窄屏工具栏布局\n- **优化渠道搜索框响应式宽度** - channel: 优化渠道搜索框响应式宽度\n- **优化驾驶舱窄屏筛选布局** - frontend: 优化驾驶舱窄屏筛选布局\n- **添加搜索框清除按钮** - desktop: 添加搜索框清除按钮\n- **优化驾驶舱筛选栏布局** - frontend: 优化驾驶舱筛选栏布局\n- **优化驾驶舱对话展示** - frontend: 优化驾驶舱对话展示\n- **精简驾驶舱统计卡片** - frontend: 精简驾驶舱统计卡片\n- **修正子代理总数统计** - frontend: 修正子代理总数统计\n- **过滤重复对话卡片** - frontend: 过滤重复对话卡片\n- **过滤重复渠道索引** - frontend: 过滤重复渠道索引\n- **修正驾驶舱卡片与统计文案** - frontend: 修正驾驶舱卡片与统计文案\n- **延迟加载全局统计图** - desktop: 延迟加载全局统计图\n- **保留快速输入清理后的 URL** - frontend: 保留快速输入清理后的 URL\n- **修正快速输入 URL 清理** - frontend: 修正快速输入 URL 清理\n- **新建/编辑渠道默认开启自动学习上游限速** - frontend: 新建/编辑渠道默认开启自动学习上游限速\n- **用 unref() 包装 store 响应式值** - frontend: 用 unref() 包装 store 响应式值\n- **过滤标题回退中的注入上下文** - backend: 过滤标题回退中的注入上下文\n- **修复托盘开机自启切换状态** - desktop: 修复托盘开机自启切换状态\n- **调整统一会话 ID 提取优先级** - backend: 调整统一会话 ID 提取优先级\n- **更新火山方舟控制台链接** - desktop: 更新火山方舟控制台链接\n- **驾驶舱展开卡片排序稳定** - Web 与桌面端会话驾驶舱在存在展开卡片时固定当前卡片顺序,避免轮询刷新或最新消息更新时间导致卡片跳动;全部收起后恢复按最新消息排序,并补充桌面端排序稳定性测试\n- **兼容 vLLM 响应中的 reasoning 字段** - providers: 兼容 vLLM 响应中的 reasoning 字段\n- **清理会话反馈残留引用** - backend: 清理会话反馈残留引用\n- **移除驾驶舱会话反馈输入框** - ui: 移除驾驶舱会话反馈输入框\n- **移除驾驶舱卡片展开折叠按钮** - ui: 移除驾驶舱卡片展开折叠按钮\n- **调整驾驶舱渠道列表高度** - ui: 调整驾驶舱渠道列表高度\n- **修复 customHeaders 覆盖 Copilot token 的安全漏洞** - copilot: 修复 customHeaders 覆盖 Copilot token 的安全漏洞\n- **修复响应协议转换与认证头泄露** - copilot: 修复响应协议转换与认证头泄露\n- **修复 ConversationChannelSequence Vuetify CSS 颜色变量 alpha 写法** - ui: 修复 ConversationChannelSequence Vuetify CSS 颜色变量 alpha 写法\n- **Compshare 月度额度耗尽自动拉黑** - `extractErrorInfo`/`extractErrorCode` 兼容大写字段名 (`RetCode`/`Message`) 和数字类型错误码;`isInsufficientBalanceMessage` 增加 `monthly limit exceeded` 等精确短语,使 429 + 月度额度耗尽错误能正确触发 key 拉黑\n- **收敛会话卡片高度避免撑爆页面** - cockpit: 收敛会话卡片高度避免撑爆页面\n- **保留原始元数据识别子代理** - conversation: 保留原始元数据识别子代理\n- **增强调试日志定位响应协议** - messages: 增强调试日志定位响应协议\n- **补齐驾驶舱看板文案** - i18n: 补齐驾驶舱看板文案\n- **注册驾驶舱图标** - web: 注册驾驶舱图标\n- **恢复驾驶舱顶部入口** - web: 恢复驾驶舱顶部入口\n\n### 优化\n\n- **优化控制台图表长周期采样** - desktop: 优化控制台图表长周期采样\n- **优化历史图表长周期采样** - frontend: 优化历史图表长周期采样\n\n### 重构\n\n- **清理会话反馈功能** - 清理会话反馈功能\n- **提取 ConversationChannelSequence 组件,消除主对话/子代理渠道列表重复** - ui: 提取 ConversationChannelSequence 组件,消除主对话/子代理渠道列表重复\n- **子代理渠道交互改为行列表,与主对话保持一致** - cockpit: 子代理渠道交互改为行列表,与主对话保持一致\n- **同步驾驶舱 BoardItem 模型到桌面端** - desktop: 同步驾驶舱 BoardItem 模型到桌面端\n- **Dashboard 适配 ConversationBoardItem 结构** - web: Dashboard 适配 ConversationBoardItem 结构\n- **重构驾驶舱子代理分组与聚合逻辑** - web: 重构驾驶舱子代理分组与聚合逻辑\n- **抽取驾驶舱列分组工具** - web: 抽取驾驶舱列分组工具\n\n### 测试\n\n- **补齐 vLLM reasoning 兼容覆盖** - providers: 补齐 vLLM reasoning 兼容覆盖\n- **强化 Gemini 图片估算测试并提速** - utils: 强化 Gemini 图片估算测试并提速\n- **补齐 copilot 包 httptest 覆盖** - copilot: 补齐 copilot 包 httptest 覆盖\n\n### 文档\n\n- **记录 Claude Code v2.1.193 变更评估** - todo: 记录 Claude Code v2.1.193 变更评估\n- **完善兼容选项提示** - frontend: 完善兼容选项提示\n- **记录 Codex 上游变更评估** - todo: 记录 Codex 上游变更评估\n- **更新 CHANGELOG 记录 Compshare 月度额度拉黑修复** - 更新 CHANGELOG 记录 Compshare 月度额度拉黑修复\n\n### 其他\n\n- **升级前端依赖** - deps: 升级前端依赖\n- **更新 Go 与 Wails 相关依赖** - deps: 更新 Go 与 Wails 相关依赖\n\n## [v2.9.17] - 2026-06-23\n\n### 新增\n\n- **桌面端会话父子关系导航** - 展示会话父子关系导航,便于在关联会话间跳转\n- **关联子代理会话记录** - 支持记录与展示子代理关联会话\n- **桌面端会话驾驶舱任务看板** - 改造会话驾驶舱任务看板,增强子代理任务概览\n- **桌面端独立驾驶舱入口** - 添加独立驾驶舱入口,提升访问效率\n- **会话反馈记录** - 支持记录会话反馈信息\n- **驾驶舱子代理看板改进** - 改进 cockpit subagent board 展示能力\n\n### 修复\n\n- **统计图表长跨度采样与展示** - 将 Web 与桌面端全局/渠道统计图默认采样点控制在 200 以内,并修复渠道级图表在仅最近几天有数据时选择 1 年导致曲线被全年空白压缩的问题\n- **桌面端能力测试渠道复制反馈** - 改进能力测试渠道复制反馈体验\n\n### 重构\n\n- **桌面端侧边栏导航分组拆分** - 拆分侧边栏导航分组,优化导航结构\n- **桌面端驾驶舱入口分离** - 将驾驶舱入口从管理控制台分离\n\n### 文档\n\n- **已完成 TODO 清理** - 移除已完成 TODO 项\n\n### 其他\n\n- **统计图表采样展示修复合并** - 合并统计图表采样展示修复\n- **桌面端开发模式启动顺序** - 调整开发模式前端启动顺序\n- **桌面端开发壳构建任务** - 添加开发壳构建任务\n\n## [v2.9.16] - 2026-06-23\n\n### 修复\n\n- **Docker 共享前端工具嵌入** - Docker 构建包含共享前端工具文件,避免容器构建缺失共享工具\n\n## [v2.9.15] - 2026-06-23\n\n### 新增\n\n- **Kimi K2.7 能力别名** - 添加 Kimi K2.7 模型能力别名,支持默认模型能力识别\n- **Qwen3.7 模型阶梯定价** - 添加 Qwen3.7 模型阶梯定价配置\n- **Codex tool_search_output 合并** - Responses 支持合并 Codex `tool_search_output` 工具输出\n- **桌面端模型下拉自动滚动定位** - 添加模型下拉列表自动滚动定位功能\n- **版本升级构建监控** - version-bump 技能新增构建进度监控流程\n\n### 修复\n\n- **Codex Responses 默认模型** - 更新 Codex Responses 共享预设默认模型\n- **桌面端渠道中心独立滚动** - 修复渠道中心滚动区域交互\n- **能力检测重试一致性** - 修复 capability retry consistency\n- **桌面端能力检测提示层裁剪** - 修复能力检测模型提示层被裁剪的问题\n- **MiMo Codex 兼容预设** - 开启 MiMo Codex 兼容预设\n- **桌面端源模型预设过滤** - 过滤已配置的源模型预设选项\n- **桌面端目标模型下拉滚动定位** - 修复目标模型下拉滚动定位\n- **桌面端目标模型完全匹配显示** - 修复目标模型完全匹配时不显示窗口的问题\n- **桌面端源模型选择展开** - 修复源模型选择后再次点击不展开列表的问题\n- **桌面端源模型下拉显示** - 修复源模型下拉列表不显示的问题\n- **桌面端模型重定向空胶囊** - 移除模型重定向源模型输入框的空胶囊元素\n\n### 重构\n\n- **渠道协议预设抽取** - 抽取渠道协议预设\n- **Codex Responses 共享预设抽取** - 抽取 Codex Responses 共享预设\n- **前端模型优先级排序共享** - 共享模型优先级排序逻辑\n\n### 测试\n\n- **用户额度不足黑名单用例** - 补充用户额度不足黑名单用例\n\n## [v2.9.14] - 2026-06-22\n\n### 新增\n\n- **Token 统计时间范围扩展到 1 年** - 新增 90 天、半年、1 年、今年等长时间范围选项\n - 后端数据保留期默认值:30 天 → 366 天,上限:90 天 → 366 天(闰年全年覆盖)\n - API 时间范围上限:30 天 → 366 天\n - 新增时间格式支持:`90d`、`180d`、`365d`、`thisyear`\n - 聚合间隔优化:长时间范围使用 8-12 小时间隔,控制数据点数量\n - Web 端和桌面端所有图表组件同步更新\n - 统一两端时间选项文案:桌面端 `7d`/`30d` → `1周`/`1月`\n - 新增中文、英文、印尼文三语言国际化文案\n- **Unity2.ai 赞助商集成** - 新增 Unity2.ai 赞助商渠道预设与国际化文案支持\n- **Unity2.ai 国际化文案** - 添加 Unity2.ai 渠道预设翻译\n- **自动赞助商集成技能** - 新增 add-sponsor skill,支持自动化赞助商集成流程\n- **对齐 Web 与桌面端渠道菜单行为** - 统一两端渠道操作菜单交互逻辑\n\n### 修复\n\n- **Codex Responses 共享预设默认模型** - 将 Kimi 默认模型同步为 `kimi-k2.7` 并补齐能力注册,GLM 默认模型升级为 `glm-5.2`,避免 Web/desktop 一键预设回退到旧模型\n- **Unity2.ai 和 RunAPI 响应剥离图片生成工具** - 为 Unity2.ai 和 RunAPI 响应剥离 image generation tool\n- **Unity2.ai 使用原生 responses 协议** - Unity2.ai 改用原生 responses 协议\n- **Unity2.ai 添加 anthropic plan 支持 Messages 目标** - 为 Unity2.ai 添加 anthropic plan 以支持 Messages target\n- **Unity2.ai targets 顺序修正** - 修正 Unity2.ai targets 顺序为标准 Messages → Responses → Chat\n- **恢复原生 tool_search 调用** - 恢复 Codex 原生 tool_search calls\n- **为 tool_search stream 发送 custom_tool_call_input.done** - 确保 tool_search 流式响应完整性\n- **保留 tool_search 历史参数** - 保持 Codex tool_search history arguments\n- **恢复 tool_search 自定义调用语义** - 恢复 tool_search custom call semantics\n- **统一两端高级选项选择框宽度与布局** - 统一 Web 和桌面端渠道编辑器高级选项布局\n- **tool_search 保留原始参数 schema 映射为 function** - converters 中 tool_search 保留原始参数 schema 映射\n- **统一渠道菜单文案与 Web 端** - 桌面端渠道菜单文案对齐 Web 端\n- **保留搜索结果渠道置顶置底操作** - 桌面端搜索结果中保留渠道排序操作\n\n### 重构\n\n- **预设配置辅助函数抽取** - 使用 helper functions 减少 preset 配置重复代码\n\n### 测试\n\n- **Codex chat 子代理工具调用测试覆盖** - 为 chat sub-agent tool calls 添加测试覆盖\n\n### 文档\n\n- **更新 add-sponsor skill 文档** - 更新 add-sponsor skill 最新使用模式\n\n### 其他\n\n- **移除 add-sponsor.sh 脚本** - 删除旧的 add-sponsor shell 脚本\n\n## [v2.9.13] - 2026-06-21\n\n### 新增\n\n- **子代理感知对话路由与亲和隔离** - 新增 subagent-aware conversation routing 与 affinity isolation,减少不同子代理会话间的路由串扰\n- **额外代理访问密钥** - 支持额外代理访问密钥配置,扩展代理入口鉴权能力\n- **内置模型预设匹配改用正则表达式** - builtin model registry 的 patterns 从简单通配符(`claude-opus-4-7*`)改为显式正则,精确控制前后边界,避免 `gpt-5.4-mini` 被 `gpt-5.4` 误吃、`glm-5.1` 被 `glm-5` 误吃等歧义;支持厂商前缀(`bedrock/claude-opus-4-7`、`xxx-claude-opus-4-7`)和日期版本号后缀(`-20260101`),同时拒绝 `claude-haiku-4-5-with-claude-opus-4-7-fallback` 这类异常组合名\n - 一个 entry 支持多个 pattern(如 `kimi-for-coding` 别名回退到 `kimi-k2.7-code`),未来 Kimi 升级到 K2.8 只需把别名移到新 entry\n - 前端 `channelPayload.ts`、桌面端 `channel-payload.ts` 匹配函数改用 `new RegExp(pattern, 'i')`\n - 后端 `model_registry.go` 新增 `builtinPatternCache` 预编译缓存,RE2 兼容剥离 `(?=$|@)` lookahead 后手动检查边界;`resolvePatternValueFold` 先正则再通配符\n - generator 加构建时正则校验\n - **用户路由级 supportedModels 配置不受影响**,继续用通配符语义\n- **手动暂停与自动熔断区分显示** - UI 区分手动暂停与自动熔断状态展示\n- **images 渠道纳入模型聚合** - 模型列表聚合纳入 images 渠道,并支持多渠道并行收集聚合\n- **Key 权重与模型选择** - Key 池新增 Weight/Models 选择能力,支持 metadata restore 测试并在前端加载 `apiKeyConfigs`\n- **按 Key 凭证池与限速** - 新增 per-key credential pool,支持按 Key 作用域进行限速\n\n### 修复\n\n- **调度器文件拆分冲突修复** - 解决 worktree 合并后的 scheduler file-split 冲突\n- **驾驶舱交互与 Key 管理** - 修复桌面端驾驶舱交互与 Key 管理若干问题\n- **驾驶舱暂停/熔断状态徽标区分** - 区分暂停与熔断状态 badge 并统一文案\n- **驾驶舱渠道选择交互** - 修复驾驶舱渠道选择交互问题\n- **渠道统计图表交互对齐** - 对齐渠道统计图表交互逻辑\n- **重复 Key 提示** - 添加重复 Key 时给出明确提示\n- **桌面 dashboard 控制台交互** - 修复桌面端 dashboard console interactions\n- **Responses 转换保持 Codex 工具兼容** - preserve Codex tool compatibility in responses conversion\n- **Dashboard 渠道编辑弹窗** - 修复桌面端 dashboard 渠道编辑弹窗问题\n- **限速高水位调度优化** - 优化调度器限速高水位策略\n- **渠道统计包含黑名单 Key** - channel stats 纳入 blacklisted keys\n- **渠道编辑对话框高度** - 优化渠道编辑对话框高度\n- **黑名单快照与配置监听** - 加固 blacklist snapshot 与 config watcher\n- **Code review 反馈问题修复** - 修复 code review 确认的 11 个 keypool/ratelimit/config 问题\n- **ChannelView 返回 apiKeyConfigs** - BuildChannelView 返回 `apiKeyConfigs`\n- **模型映射同步后再能力测试** - 前端 capability test 前同步 model mapping\n\n### 重构\n\n- **配置更新逻辑抽取** - 抽取 `applyAPIKeyConfigUpdate` 并改进 watcher 防抖策略\n- **前端活动可视化与单测** - 改进 activity visualization 并补充 unit tests\n- **ChannelOrchestration 样式拆分** - 从 `ChannelOrchestration.vue` 抽取样式\n- **EditChannelModal 样式与优先级逻辑拆分** - 从 `EditChannelModal.vue` 抽取样式和 model-priority 逻辑\n- **App 样式与能力测试组合式函数拆分** - 从 `App.vue` 抽取样式和 capability-test composable\n- **ChannelOrchestration 活动组合式函数抽取** - 抽取 `useChannelActivity` composable\n- **Responses handler 拆分** - 拆分 `handler.go`(2160 行 → 5 个文件)\n- **Chat handler 拆分** - 拆分 `handler.go`(2305 行 → 5 个文件)\n- **Scheduler 测试拆分** - 拆分 `channel_scheduler_test.go`(1650 行 → 3 个文件)\n- **Scheduler 实现拆分** - 拆分 `channel_scheduler.go`(1535 行 → 4 个文件)\n- **通用 stream helper 拆分** - 拆分 `handlers/common/stream.go`(2799 行 → 4 个文件)\n- **Metrics 实现拆分** - 拆分 `channel_metrics.go`(3731 行 → 5 个文件)\n\n### 文档\n\n- **待办事项更新** - 更新 TODO 文档\n\n### 其他\n\n- **额外代理访问密钥合并** - 合并额外代理访问密钥支持\n- **渠道编辑对话框高度修复合并** - 合并渠道编辑对话框高度修复\n- **黑名单 Key 统计修复合并** - 合并 blacklisted key stats fix\n- **Go 格式化** - 执行 go fmt 格式化\n\n## [v2.9.12] - 2026-06-19\n\n### 修复\n\n- **上下文窗口按输入 token 过滤** - 调度器按输入 token 过滤 context windows,避免选择不满足输入上下文要求的渠道\n- **Docker 前端嵌入构建稳健性改进** - 改进 Docker frontend embed build robustness,提升前端产物嵌入构建稳定性\n\n## [v2.9.11] - 2026-06-19\n\n### 修复\n\n- **桌面控制台 fallback 文案本地化** - 桌面端控制台 fallback labels 使用多语言文案,避免未翻译标签展示\n\n## [v2.9.10] - 2026-06-19\n\n### 新增\n\n- **上游认证头覆盖配置** - 后端新增 upstream auth header override,支持按渠道覆盖上游认证头\n- **渠道认证头设置入口** - 前端暴露 channel auth header 设置,支持管理界面配置认证头覆盖\n- **OpenCode Go 预设 x-api-key** - 桌面端 OpenCode Go 预设自动设置 x-api-key 认证头\n\n### 修复\n\n- **模型能力标签简化** - 简化前端 model capability labels,改善模型能力展示文案\n\n### 其他\n\n- **SignPath artifact 安全暂存** - SignPath artifacts 使用安全名称暂存,避免签名流程中的产物名称冲突\n\n## [v2.9.9] - 2026-06-19\n\n### 其他\n\n- **SignPath 签名步骤唯一 artifact 名称** - 为 SignPath 签名步骤添加唯一 artifact 名称,避免多平台并行构建时名称冲突\n\n## [v2.9.8] - 2026-06-19\n\n### 新增\n\n- **OpenCode Zen/Go 渠道预设更新至 glm-5.2** - 桌面端 OpenCode Zen/Go 预设的 Claude 与 Codex 目标模型映射更新为 glm-5.2 / deepseek-v4-flash,并补齐对应 reasoning 映射\n\n### 修复\n\n- **视觉回退模型上下文能力自动添加** - 前端和桌面端在上下文能力自动添加时纳入视觉回退模型,并按大小写不敏感方式去重,避免回退模型缺失能力配置行\n- **前端 lint 存量问题清理** - 修复 Web 前端 ESLint 存量错误与警告,清理编辑渠道弹窗拆分后的未使用旧逻辑并补齐相关类型声明\n- **Responses SSE 扫描缓冲区上限提升** - 将 Responses 流式 SSE 单行扫描上限提升至 32MB,避免图片/产物等大事件被截断导致流式失败\n\n### 其他\n\n- **upstream-check seen 标签数量限制** - 限制 upstream-check skill 记录的 seen upstream tags 数量,避免状态无限增长\n\n## [v2.9.7] - 2026-06-19\n\n### 新增\n\n- **模型响应模态信息增强** - 增强 models 接口响应中的 modalities 信息\n\n### 修复\n\n- **前端模型目标建议修复** - 修复前端模型 target suggestions 展示与匹配问题\n- **发布暂存目录可见性修复** - 使用可见 release staging directory,修复发布打包暂存路径问题\n\n## [v2.9.6] - 2026-06-19\n\n### 修复\n\n- **移除 agent profile 窗口硬最低限制** - 移除桌面端 agent profile 窗口硬最低限制,新增上下文估算日志\n- **简化模型能力定价编辑器** - 简化前端 model capability pricing editor 交互\n- **修复 capability test 模型映射复用** - 修复 capability test 模型映射复用问题\n- **优化模型能力行布局** - 优化桌面端 model capability row layout\n- **改善 capability test 状态处理** - 改善桌面端 capability test status handling\n- **同步桌面端模型能力配置** - 同步桌面端 model capabilities 配置\n\n## [v2.9.5] - 2026-06-19\n\n### 新增\n\n- **桌面端模型能力编辑器改进** - 改进桌面端 model capability editor 交互与布局\n- **前端模型能力编辑器改进** - 改进前端 model capability editor 交互与布局\n- **DeepSeek V4 能力更新** - 更新 model registry 中 DeepSeek V4 模型能力信息\n\n### 其他\n\n- **SignPath 测试签名校验放宽** - 放宽 SignPath test signature validation,提升测试环境签名校验兼容性\n\n## [v2.9.4] - 2026-06-19\n\n### 新增\n\n- **上游模型能力注册表** - 新增 upstream model capability registry,用于集中维护上游模型能力信息\n- **上下文感知渠道路由** - 新增 context-aware channel routing,根据请求上下文与模型能力选择更合适的渠道\n\n### 修复\n\n- **桌面端目标模型选项来源修复** - 桌面端使用已拉取的目标模型选项,避免候选模型与上游能力不一致\n- **桌面端渠道能力测试流程对齐** - 对齐桌面端渠道 capability test 流程,提升测试结果一致性\n- **桌面端渠道重排序类型处理修复** - 修复桌面端 channel reorder 的类型处理问题\n\n### 其他\n\n- **SignPath 签名集成** - CI 集成 SignPath signing,完善发布签名流程\n\n## [v2.9.3] - 2026-06-18\n\n### 新增\n\n- **讯飞星辰渠道预设** - 新增 ProviderXFyun 预设,Anthropic + OpenAI 双入口,支持 Messages/Chat/Responses 三目标,含快速输入 URL 识别与中英文 locale\n- **Codex SQLite 迁移修复 visibility 字段** - migrateCodexStateDB 动态检测 threads 列,自动回填 preview/has_user_event/thread_source 等隐藏线程可见性字段\n- **中转站错误码 failover 识别增强** - 增强中转站错误码识别与黑名单判定,提升故障转移覆盖\n- **渠道运行态冷却与上游账号池降级** - 调度器新增渠道运行态冷却与上游账号池不可用降级策略\n- **Compshare glm-5.2 预设与推理映射同步** - 桌面端 ChannelEditDialog 同步 Compshare glm-5.2 预设与 reasoning 映射\n- **火山方舟 Compshare 预设更新至 glm-5.2** - 更新桌面端火山方舟 Compshare 渠道预设模型\n\n### 修复\n\n- **渠道日志按稳定渠道名过滤共享 metricsKey** - GetChannelLogs 对共享 metricsKey 优先按创建时渠道名筛选,旧日志回退 channelIndex,避免删除/重排后日志串台\n- **Codex SQLite visibility 回填来源保护** - 迁移 threads 时根据 source 列跳过 exec 等后台线程,避免误标记为用户会话\n- **trace 亲和性遵守 SupportedModels** - 新增测试验证亲和渠道不支持当前模型时正确跳过并回退优先级选择\n- **共享 metricsKey 渠道日志按渠道名归属** - 修复共享 metricsKey 场景下渠道日志归属到稳定渠道名\n- **共享 metricsKey 渠道日志按 channelIndex 过滤** - 修复渠道日志在共享 metricsKey 时按 channelIndex 过滤\n- **短流 EOF 重试策略** - 在 message_stop 前遇到短流 EOF 时进行重试\n- **新建渠道使用生成名称提交** - 修复桌面端新建渠道提交时未使用生成名称的问题\n\n### 文档\n\n- **CHANGELOG 维护更新** - 更新 CHANGELOG,补充讯飞星辰、Codex 迁移修复、日志过滤与调度测试记录\n\n## [v2.9.2] - 2026-06-18\n\n### 新增\n\n- **火山方舟预设模型与推理映射更新** - 更新桌面端火山方舟渠道预设模型与 reasoning 映射\n- **渠道卡片缓存命中率展示** - 桌面端渠道卡片新增缓存命中率显示\n- **渠道添加偏好卡片式交互** - 渠道添加流程改为卡片式交互,提升偏好选择体验\n- **渠道日志 reasoning effort 展示同步** - 桌面端渠道日志同步展示 reasoning effort 信息\n- **渠道支持选择队列末尾并记住偏好** - 前端新增渠道支持放到队列末尾,并记住用户偏好\n- **CompactionV2 本地 compact 与 SSE 转换** - Responses 实现 CompactionV2 本地压缩并支持 SSE 转换\n- **Passthrough converter ResponsesItem 字段映射补全** - passthrough converter 补齐 ResponsesItem 全量字段映射\n- **ResponsesItem EncryptedContent 字段** - 类型定义新增 EncryptedContent 字段以支持 compaction\n- **视觉回退 reasoning effort 与 output_config.effort 同步** - 视觉回退模型支持 reasoning effort,并同步 output_config.effort\n- **渠道日志 reasoning effort chips 展示** - 前端渠道日志弹窗新增 reasoning effort chips\n- **视觉回退 reasoning effort 选择器与多语言文案** - 前端新增视觉回退 reasoning effort selector 和 locale keys\n- **Claude 渠道 reasoning mapping 支持** - 前端扩展 reasoning mapping 支持到 claude channel type\n- **Claude thinking effort 渠道映射注入** - providers 根据渠道 reasoningMapping 注入 Claude thinking effort\n- **ChannelLog reasoning effort 字段** - metrics 新增 originalReasoningEffort 与 actualReasoningEffort\n- **渠道日志 reasoning effort 提取 helpers** - handlers 新增 reasoning effort 提取辅助函数\n\n### 修复\n\n- **火山方舟 Responses 视觉回退补齐** - 桌面端补齐火山方舟 Responses 视觉回退配置\n- **orchestration promotion 标签文案优化** - 优化桌面端 orchestration promotion 标签文案\n- **legacy reasoning params 转换顺序修复** - providers 在重写为 block format 前先转换 legacy reasoning params\n- **模型映射 noVision 同步修复** - 按目标模型改进 noVision 在 model mappings 间的同步逻辑\n- **模型映射 noVision flag 同步修复** - 修复 noVision flag 按 target 在 model mappings 间同步\n- **Add/Edit Channel Modal 缺失导入修复** - 补充 AddChannel/EditChannel modals 中 normalizeSelectableString 导入\n- **Chat reasoning effort 映射模型来源修复** - Chat 使用实际请求体 model 进行 reasoning effort mapping\n- **重复 reasoning effort 标签合并** - 合并 UI 中重复的 reasoning effort 标签显示\n\n### 重构\n\n- **EditChannelModal 请求 URL 构建统一** - 统一 EditChannelModal 使用 buildExpectedRequestUrls\n- **AddChannelModal 快速添加职责收敛** - 移除 AddChannelModal 编辑模式,仅保留 quick-add\n- **reasoning effort 校验逻辑统一** - config 使用 isValidReasoningEffort 统一 reasoning effort validation\n\n## [v2.9.0] - 2026-06-17\n\n### 新增\n\n- **运行时超时热更新与 responseHeaderTimeout 渠道字段** - 新增 `responseHeaderTimeoutMs` 渠道字段,支持全局运行时超时热更新,httpclient 支持显式 responseHeaderTimeout\n- **熔断器/调校台新增超时滑块** - 桌面端和前端熔断器、调校台新增 `requestTimeoutMs` 与 `responseHeaderTimeoutMs` 运行时滑块\n- **CompactModel 配置支持** - Responses 渠道新增 `compactModel` 配置字段,支持压缩模型自动选择\n- **内联模型映射编辑器** - 所有渠道类型支持内联编辑模型映射,无需弹窗\n- **主动限速滑动窗口算法** - 令牌桶改为滑动窗口算法,支持可配置的窗口时长,自动学习上游限速默认开启\n- **b64_json 图片响应自动转换** - 支持 `b64_json` 请求的 URL 响应自动转换\n- **向下游下发可读的空响应错误文案** - 流式响应场景向客户端下发可读错误信息而非空流\n- **桌面端 App 级 polling 调度层** - ConversationDashboard 轮询迁移至 App 级调度,支持 window visibility 事件控制\n- **桌面端图表功能对齐 Web 端** - 桌面端控制面板使用统计图表与 Web 端完全对齐\n- **渠道活动图表背景** - ChannelCard 新增活动图表背景可视化\n- **渠道快速创建卡片上游类型品牌色与图标** - 快速创建渠道卡片的上游类型展示品牌色和对应图标\n- **渠道编辑器输出冗长度配置** - 渠道编辑器新增输出冗长度配置选项\n- **驾驶舱空闲自动恢复时间持久化配置** - 支持通过环境变量和前端选择器持久化配置 override 有效期\n- **Kimi Coding Plan 模型重定向与智能排序** - Kimi 渠道实现 Coding Plan 模型重定向与智能模型排序\n- **桌面端模型重定向区域 premium UI 重构** - 目标模型输入改为自定义下拉菜单,支持下拉建议\n- **快速模式与历史图片轮次限制配置** - Web 端补全快速模式与历史图片轮次限制配置\n- **流式断流超时预设快捷按钮** - 桌面端新增流式断流超时预设快捷按钮\n- **Esc 快捷键提示** - Web 和桌面端为对话框关闭按钮添加 Esc 快捷键提示\n- **Compatibility 分组高级开关补全** - Web 端和桌面端补全 Compatibility 分组所有缺失的高级开关\n- **桌面端 i18n 全面国际化** - 迁移到 vue-i18n + 外部 JSON 语言文件,桌面端渠道编辑、控制台、图表等全面 i18n 化\n- **文档: Geo SEO, llms.txt, README 桌面安装说明** - 新增 Geo SEO、llms.txt,改进贡献指南和 README 桌面安装说明\n\n### 修复\n\n- **临时限流不触发 Key 黑名单** - 临时限流场景不再误将 Key 加入黑名单\n- **REQUEST_TIMEOUT 默认值从 300s 调整为 120s** - 调整默认请求超时为更合理的 120 秒\n- **保留 web_search 工具以兼容 Codex v0.139.0+** - Responses 场景保留 web_search 工具兼容新版 Codex\n- **修复 UpstreamUpdate JSON 标签拼写错误** - 修正配置结构体 JSON tag 拼写\n- **图表系列修复** - 修复 KeyTrendChart yaxis 绑定、series 复用、forceNiceScale、失败率柱透明度等多项图表问题\n- **模型映射编辑修复** - 修复 modelMapping 对象值保存失败、combobox 值规范化、目标模型输入框抖动等问题\n- **限速配置修复** - 修复 0 值显示为空、窗口时长清空后无法保存、rateLimitWindowMinutes 字段遗漏等\n- **桌面端控制台组件修复** - 修复组件缺失导入、滚动导航、Esc 键 window 全局监听等\n- **i18n 修复** - 修复英文模式下统计图表中文、id.json 时长单位错误、useI18n setup 上下文时序等\n- **resumeChannel 后自动调用 setStatus('active')** - 恢复渠道后正确更新状态\n- **REQUEST_TIMEOUT/RESPONSE_HEADER_TIMEOUT 降级为启动兜底** - 超时配置运行时以调校台为准,环境变量仅作启动兜底\n\n### 重构\n\n- **渠道编辑器全面重构** - 左侧导航+右侧面板布局,拆分快速添加与编辑弹窗,统一内容顺序对齐 WebUI\n- **高级选项分组重构** - 创建 Runtime 运行期策略分组,统一布局结构,主动限速拆分为独立 RateLimitGroup\n- **模型映射区域深度重构** - 行内可编辑、视觉回退模型行内开关驱动,优化布局与样式\n- **快速添加面板重构** - 紧凑两栏布局、卡片式分块布局、上游类型选择器标题行后缀推荐项\n- **渠道卡片状态展示重构** - 统一交互逻辑、展开按钮与统计图表折叠优化\n- **熔断器/调校台滑块设计重构** - 参考 Web 端样式,增强可见性和对比度\n- **桌面端渠道编辑创建流程重构** - 统一使用 t 多语言函数,对齐内容顺序与 WebUI\n- **vue-i18n 迁移** - 从自定义 i18n 迁移到 vue-i18n + 外部 JSON 语言文件,统一两端 key 命名空间\n- **ChannelManager/ChannelTab 惰性加载** - 非活跃页跳过刷新,提升性能\n- **CircuitBreakerDialog 布局重构** - header/footer 固定,body 独立滚动\n\n### 文档\n\n- **新增 RESPONSE_HEADER_TIMEOUT 与运行时超时配置说明**\n- **更新 compactModel 说明,明确 Responses 渠道 failover 机制**\n\n## [v2.8.28] - 2026-06-11\n\n### 新增\n\n- **桌面端管理面板集成用量统计图表 (#199)** - 管理面板新增用量统计图表展示\n- **补齐渠道中心所有 provider 到 Agent 直连设置** - 完善 Agent 直连配置,覆盖所有渠道中心 provider\n- **驾驶舱 Override 有效期选择器补齐** - 驾驶舱 Override 配置界面新增有效期选择器\n- **upstream-check 新增功能与体验变更报告输出步骤** - 上游版本检查 skill 新增变更报告输出\n\n### 修复\n\n- **完善驾驶舱 i18n 并移除重复的系统状态显示** - 优化驾驶舱国际化文案,移除重复状态展示\n- **补全 tooltip content-class 并加大快捷键提示间距** - 修复 tooltip 样式类缺失,优化快捷键提示视觉间距\n- **补全 stream.go 中 message_delta 的 stop_details 字段** - 流式响应 handlers 补充 stop_details 字段处理\n- **添加 stop_details 字段到 message_delta** - providers 层新增 stop_details 字段支持\n- **过滤 compaction 流式响应中的 reasoning items (#179)** - Responses compaction 场景过滤推理内容,避免重复输出\n- **优化弹窗快捷键提示体验** - 改进弹窗快捷键提示交互\n- **Override 有效期标签改为用户易懂的「空闲自动恢复」** - 重写 Override 配置文案,提升用户理解\n- **有效期选择器默认选中 30min + 补充长时段选项** - 优化有效期默认值与选项范围\n- **修复磁贴图标透明背景与黄色光圈,补齐 Windows 磁贴资产** - 修复桌面端 Windows 图标透明度问题,补全磁贴资源\n- **千帆 Coding Plan 渠道补充模型映射与特性配置** - 完善千帆编码计划渠道模型映射\n- **火山方舟 Coding Plan 渠道补充模型映射与特性配置 (#204)** - 完善火山方舟编码计划渠道模型映射\n- **替换已归档官方镜像为社区维护 fork,修复 Docker API 版本兼容性问题** - Watchtower 迁移至社区维护镜像,修复 API 兼容性\n\n### 优化\n\n- **前端构建增加双层缓存,避免重复编译** - 前端构建流程新增缓存机制,加速构建\n\n### 改进\n\n- **驾驶舱 Override 有效期可配置** - 支持环境变量 `OVERRIDE_TTL_MINUTES`(默认 30 分钟)和前端下拉选择器(15min/30min/1h/2h/永不恢复)自定义 override 有效期\n- **Override Idle 续期** - 对话活跃时自动续期 override TTL,仅在闲置超过设定时间后才恢复默认调度\n- **Override 永不恢复选项** - 支持设置 override 永不过期,手动恢复前不会自动过期\n\n### 文档\n\n- **注释说明极易云 originrouter 刻意不纳入 Agent 直连** - 渠道预设注释补充设计决策说明\n- **新增 TODO #199 桌面端用量显示与 #179 compaction v2 think 拆分问题** - 更新待办事项\n- **完成 TODO #1 OpenRouter 免费路由工具调用排查,确认为上游限制** - 标记已完成任务\n- **清理已完成任务并添加三个新 TODO** - 维护任务列表\n\n### 变更文件\n\n- `backend-go/internal/conversation/override.go` - IsPerpetual、per-call duration、RefreshOverrideForUser、SetDefaultTTL\n- `backend-go/internal/conversation/override_test.go` - 新增 8 个测试用例\n- `backend-go/internal/config/env.go` - 新增 OVERRIDE_TTL_MINUTES 环境变量\n- `backend-go/main.go` - 读取环境变量替换硬编码 30 分钟\n- `backend-go/internal/handlers/conversation_handler.go` - API body 新增 duration 参数,响应新增 isPerpetual\n- `backend-go/internal/scheduler/channel_scheduler.go` - SelectChannel 成功消费 override 时调用 RefreshOverrideForUser\n- `frontend/src/components/ConversationDashboard.vue` - 新增 override 持续时间下拉选择器\n- `frontend/src/components/ConversationCard.vue` - 永久 override 展示逻辑\n- `frontend/src/services/api-types.ts` - SequenceOverrideInfo 新增 isPerpetual\n- `frontend/src/services/api.ts` - setConversationOverride 新增 duration 参数\n- `frontend/src/i18n/messages-{zh-cn,en,id}.ts` - 新增 4 个 i18n key\n\n## [v2.8.27] - 2026-06-10\n\n### 新增\n\n- **桌面端 MiniMax M3 渠道预设** - 新增 MiniMax M3 channel presets\n- **桌面端讯飞星辰 MAAS 直连支持** - 添加讯飞星辰 MAAS 桌面端 Agent 直连支持\n- **弹窗快捷键提示与确认交互** - 所有弹窗添加 ESC 取消与 Cmd/Ctrl+Enter 确认快捷键提示,提升操作效率\n\n### 修复\n\n- **Responses serviceType claude 路径认证修复** - serviceType claude 路径正确使用 x-api-key 认证头\n- **桌面端 OpenCode 讯飞星辰直连精确移除** - 仅移除 OpenCode 的讯飞星辰直连,保留 Claude/Codex 路径\n- **Fable reasoning 迁移补齐 model mapping** - migrate fable reasoning mapping with model mapping\n- **REWRITE_RESPONSE_MODEL 支持 Responses API** - Responses API 场景正确支持 REWRITE_RESPONSE_MODEL 重写\n\n### 重构\n\n- **统一 codex responses 预设 source key** - 统一 codex responses 预设 source key 为 codex/gpt/mini\n\n### 其他\n\n- **Responses provider 认证头 serviceType 分支测试** - 添加 Responses provider 认证头 serviceType 分支测试用例\n\n## [v2.8.26] - 2026-06-10\n\n### 新增\n\n- **Claude Fable 模型支持** - 新增 Claude Fable 系列模型映射与兼容处理,升级时自动补齐 Fable 模型映射配置\n- **渠道级主动限速与上游 rate limit header 自动追踪 (#190)** - 新增渠道级令牌桶限速、并发控制与 cooldown 机制,自动解析上游 `X-RateLimit-*` / `Retry-After` 响应头并动态调整限速参数,MiMo 默认 RPM=80\n- **历史图片轮次限制与占位符替换** - 新增渠道级历史图片轮次限制配置(`historicalImageTurnLimit`),超过指定轮次的历史对话图片自动替换为 `[Image]` 占位符,避免不必要的 vision 回退模型切换。覆盖 Claude Messages、OpenAI Chat、Responses API、Gemini 四种协议格式,Web/桌面渠道编辑 UI 支持按渠道配置\n- **渠道体验增强与 Messages 渠道级 CCH 开关** - Web/桌面渠道列表新增 15 分钟窗口”缓存写偏高”提醒 badge;桌面端 RunAPI messages 预设默认关闭 `normalizeMetadataUserId`;`stripBillingHeader` 从全局设置下沉为 messages 渠道级开关,默认关闭,并同步补齐 Web/桌面渠道编辑表单与老配置迁移逻辑\n- **桌面端 OpenRouter 与 ModelScope 渠道预设** - 新增 OpenRouter、ModelScope 渠道中心预设,并将 Kimi Code Plan 合并入 Kimi 预设作为编码计划条目,添加到 Claude/Codex/OpenCode 直连配置列表\n- **桌面端渠道预设扩充** - 新增 RunAPI 赞助商预设、Tencent TokenHub 预设与国内 coding-plan 网关预设,统一 RunAPI 与 Token Plan 文案,优化 RunAPI 渠道预设排序\n- **桌面端管理面板 Fuzzy 开关与熔断器配置** - 管理面板工具栏新增 Fuzzy 模式开关和熔断器配置按钮,熔断器配置预设值、slider 范围与 WebUI 同步对齐\n- **桌面端 Codex/Responses 分层排障诊断** - 在桌面 Agent 页 Codex 卡片新增分层排障,按顺序检查”Codex 配置一致性 → Responses 渠道可用性 → 最近失败请求”,帮助定位 #156 类”本地网关可达但请求失败”的问题。后端扩展 `AgentConfigStatus`(新增 `authMode`/`configConsistent`/`diagnosticCode`/`diagnosticMessage`),在 `getCodexStatus` 中识别 CCS 等工具导致的 `config.toml` 与 `auth.json` 不一致(缺 key、auth_mode 不匹配、插件模式缺 bearer token、旧式配置残缺、配置污染等);前端新增 `useResponsesDiagnostics` composable,聚合 Codex 配置层、Responses 渠道层(无渠道/全禁用/无 key/熔断/协议错配)与最近失败日志层(401/403、429、5xx、超时)的诊断结论,并在状态页提供最高优先级问题摘要。第一版仅给出诊断与手动修复建议,不做自动修复。涉及 `desktop/internal/configservice/service.go`、`desktop/frontend/src/composables/useResponsesDiagnostics.ts`、`desktop/frontend/src/components/agent/*`、`desktop/frontend/src/components/status/StatusTab.vue`、`desktop/frontend/src/i18n/messages.ts`、`desktop/frontend/src/types/index.ts`\n- **桌面端 dashboard 显示运行态当前渠道** - 桌面端 dashboard 新增运行态当前渠道信息展示\n- **简化前端添加渠道流程** - 优化前端渠道添加交互流程\n\n### 修复\n\n- **桌面端 Codex 排障忽略 UI 选择的目标模式** - 排障诊断只检查 config.toml / auth.json 文件自洽性,未考虑用户在 UI 上选择的 Codex 模式(快捷/插件),导致选了快捷模式但磁盘仍是插件模式时误报”配置一致”。修复后前端排障会比较 `status.mode`(磁盘实际模式)与 `codexMode`(UI 目标模式),不一致时优先报模式冲突并提示重新应用。涉及 `desktop/frontend/src/composables/useResponsesDiagnostics.ts`、`desktop/frontend/src/i18n/messages.ts`\n- **桌面端 Codex 排障按需触发** - Codex 排障诊断改为按需触发,避免不必要的自动诊断开销\n- **桌面端渠道介绍 i18n 对齐** - 补齐渠道介绍缺失的 key 与中文产品描述\n- **桌面端 Fuzzy 模式 API 路径补全** - Fuzzy 模式 API 路径自动补全 `/api` 前缀并改为 watch 驱动加载\n- **桌面端熔断器配置预设值同步** - 同步熔断器配置预设值、slider 范围与 WebUI 一致\n- **桌面端 fetch 网络层错误包装** - 包装 fetch 网络层错误,避免管理面板显示原始 `Failed to fetch` 信息\n- **桌面端切换渠道目标时清除成功提示条** - 修复切换渠道目标时残留上一目标操作结果的问题\n- **桌面端补齐 claudeProviderKeys 缺失键** - 补齐 claudeProviderKeys 缺失的 openrouter/modelscope 键,修复 TS2345 类型错误\n- **桌面端当前渠道显示补上渠道名称** - 修复当前渠道显示缺失渠道名称的问题\n- **桌面端渠道预设描述统一** - 以平台身份重写渠道预设描述,统一 MiniMax M3 旗舰描述文案\n- **桌面端 agent 配置绑定同步** - 同步 agent 配置绑定关系\n- **按 content block index 管理 tool_use 缓冲** - 修复重叠 tool_use 导致 `Content block not found` 错误\n- **preflight 对未知 SSE 事件类型增加内容兜底识别** - Responses preflight 阶段对未知 SSE 事件类型增加内容兜底识别逻辑\n- **识别 messages→responses 桥接下的上游错误** - 修复 messages→responses 桥接场景下上游错误/失败/reasoning 事件未被正确识别的问题\n- **调整熔断器预设超时值** - 均衡档改为 60s/60s/180s\n- **渠道级流式超时预设与全局调校台对齐** - 渠道级流式超时预设值与全局调校台保持一致\n- **补齐渠道调校台参数同步** - 修复渠道级调校台参数未与全局配置同步的问题\n- **优化 RunAPI 渠道预设与排序** - 优化 RunAPI 渠道预设配置与列表排序\n- **tune 工具调用空闲处理** - 调整流式工具调用空闲超时参数\n- **加固本地 compact prompt** - 加固 Responses compact 本地提示词\n\n### 重构\n\n- **合并历史图片轮次限制到熔断器对话框** - 将历史图片轮次限制配置合并到熔断器对话框中,减少工具栏按钮数量\n- **从 EnvTab 移除熔断器配置卡片** - 从 EnvTab 移除熔断器配置卡片,统一由管理面板弹窗管理\n- **熔断器设置更名为调校台 Tuning Bench** - 将熔断器设置相关 UI 文案统一更名为”调校台 Tuning Bench”\n\n### 文档\n\n- **上游评估 TODO 更新** - 添加上游评估待办事项,记录 #190 新特性条目与上游协议变更\n- **upstream-check TODO 格式对齐** - 对齐上游检查脚本 TODO 分组标题层级与任务格式\n\n### 其他\n\n- **将上游检查脚本迁入技能目录** - 将上游版本检查脚本从 tools/ 迁入技能目录,优化技能目录结构\n\n## [v2.8.25] - 2026-06-07\n\n### 新增\n\n- **RunAPI 赞助商与桌面端直连预设** - 新增 RunAPI 赞助商展示、渠道中心预设与 Claude/Codex/OpenCode 直连配置,支持 Messages、Chat、Responses 三类目标\n- **流式 post-commit 阶段畸形工具调用截断策略** - 文本已输出后遇到畸形 `tool_use` 时缓冲并截断工具调用,注入 `end_turn` 与 `message_stop` 正常结束,避免客户端进入无效 `tool_result` 重试\n- **Responses 添加 tool_search 工具类型识别与兼容处理** - 新增 tool_search 工具类型的识别与兼容处理逻辑\n- **新增 stripImageGenerationTool 渠道开关** - 渠道配置新增 stripImageGenerationTool 开关,支持过滤图片生成工具\n- **桌面端同步 stripImageGenerationTool 渠道开关** - 桌面端编辑渠道界面同步支持 stripImageGenerationTool 开关\n- **上游版本每日自动检查 skill 与脚本** - 添加上游版本每日自动检查 skill 与脚本\n- **渠道管理界面增强与国际化完善** - Web UI 渠道管理界面增强与国际化完善\n- **新增流式响应观察者模式** - 流式响应新增观察者模式,支持可插拔的流式处理逻辑\n- **上游请求日志接入请求上下文标签** - 为上游请求日志接入 session/round 请求上下文标签\n- **新增请求日志上下文模块** - 新增请求日志上下文模块支持 session/round 标签\n- **流式响应 TPS 进度记录** - 记录流式响应 TPS 进度\n- **流式 200 伪成功检测与 failover 机制** - 新增流式 200 伪成功检测与 failover 机制\n- **Desktop 前端熔断器配置新增流式超时滑块** - 桌面端熔断器配置界面新增流式超时滑块\n- **Web 前端熔断器配置新增流式超时滑块** - Web 前端熔断器配置界面新增流式超时滑块\n- **Gemini/Images 流式 preflight 适配** - 完成 Gemini/Images 流式 preflight 适配\n- **Gemini handler 初步接入流式 preflight 检测** - Gemini handler 初步接入流式 preflight 检测\n- **Responses handler 流式两阶段 preflight 检测** - Responses handler 接入流式两阶段 preflight 检测\n- **Chat handler 流式两阶段 preflight 检测** - Chat handler 接入流式两阶段 preflight 检测\n- **Minimax 2.7 思考标签处理升级** - 升级 Minimax 2.7 思考标签处理为协议级提取与原生推理字段转换\n\n### 修复\n\n- **记录缓冲工具事件的原始流日志** - 修复 tool_use 缓冲期间原始 SSE 事件未写入响应日志的问题,确保工具开始、参数增量与结束事件可追踪\n- **渠道中心预设顺序与 RunAPI Responses 文案优化** - 保持后端预设顺序避免按 Key 分组导致列表跳位,并为 RunAPI Responses 目标显示原生 Responses 方案文案\n- **桌面端切换渠道时清除成功提示与错误状态** - 修复切换渠道时旧的成功提示和错误状态残留的问题\n- **修复三个疑似 bug (#162 #187 #188)** - 修复三个疑似 bug\n- **延长流式空闲超时限制** - 延长 stream idle timeout limits\n- **保留流式 preflight 诊断信息** - 保留 stream preflight diagnostics\n- **修复 Windows 图标透明度并补全 Codex 预设模板** - 修复桌面端 Windows 图标透明度并补全 Codex 预设模板\n- **添加 guide/ 重定向文件修复 GitHub 链接 404** - 修复 docs/guide 重定向导致的 GitHub 链接 404\n- **将完整工具调用视为非空流** - 修复完整工具调用被误判为空流的问题\n- **修复 JSON 日志截断破坏 Unicode 字符** - 修复 JSON 日志截断时破坏 Unicode 字符的问题\n- **修复跨协议能力测试虚拟协议检测** - 修复跨协议能力测试中虚拟协议检测逻辑\n- **修正 MSIX 数据目录显示路径** - 修正桌面端 MSIX 数据目录显示路径\n- **流式工具调用使用 idle timeout** - 修复流式工具调用使用正确的 idle timeout\n- **分离工具调用流式超时** - 将工具调用流式超时与普通流式超时分离\n- **修复 compact 流式转换状态 panic** - 修复 Responses compact 流式转换状态 panic\n- **捕捉已提交流式响应断流** - 修复已提交流式响应断流未被捕捉的问题\n- **移除流式超时 off 档并为渠道新增超时覆盖开关** - 移除流式超时 off 档并为渠道新增超时覆盖开关\n- **修复工具调用流式 preflight 误判** - 修复 Chat 工具调用流式 preflight 误判\n- **修复 restoreApiKey 接口请求路径** - 修复桌面端 restoreApiKey 接口请求路径\n- **修复编辑渠道对话框拉黑密钥恢复按钮不可点击** - 修复桌面端编辑渠道对话框拉黑密钥恢复按钮不可点击\n- **修复编辑渠道对话框拉黑密钥恢复按钮不可点击 (Web)** - 修复 Web 端编辑渠道对话框拉黑密钥恢复按钮不可点击\n- **规范化 compact reasoning 输出** - 修复 Responses compact reasoning 输出格式\n- **修复频道菜单缺失 i18n** - 修复桌面端频道菜单缺失 i18n 翻译\n- **对齐管理面板协议显示名** - 修复桌面端管理面板协议显示名不一致\n- **修复 Minimax 2.7 模型 think 标签问题** - 修复 minimax 2.7 模型有 `` 标签的问题\n\n### 重构\n\n- **按语言拆分 i18n 消息文件** - 前端 i18n 消息文件按语言拆分\n- **拆分 API 服务层类型和工具** - 前端 API 服务层类型和工具拆分\n- **移除 Wails v3 系统通知服务** - 桌面端移除 Wails v3 系统通知服务\n\n### 测试\n\n- **补充 RunAPI 桌面端预设与 Agent 配置回归测试** - 覆盖 RunAPI Messages/Chat/Responses 渠道 payload、官网链接、Claude 状态识别与 Key 复用、Codex 快捷/插件模式、OpenCode 直连配置\n- **GPT 类渠道探测模型覆盖 codex-auto-review** - GPT 类渠道探测模型新增 codex-auto-review 覆盖\n- **覆盖无模型映射的协议转换测试组** - 新增无模型映射的协议转换测试组覆盖\n- **拆分 chat_to_responses 测试文件** - 拆分 converters chat_to_responses 测试文件\n- **拆分 configservice 测试文件** - 拆分桌面端 configservice 测试文件\n- **拆分 failover 测试文件** - 拆分 common failover 测试文件\n\n### 文档\n\n- **更新 TODO 格式规范与疑似 bug 修复记录** - 更新 TODO 格式规范与疑似 bug 修复记录\n- **更新 TODO 完成状态与关键提交记录** - 更新 TODO 完成状态与关键提交记录\n- **添加 GPT 类渠道模型测试覆盖待办** - 添加 GPT 类渠道模型测试覆盖待办\n- **新增桌面端渠道成功提示清理待办** - 新增桌面端渠道成功提示清理待办\n- **精简历史版本记录** - 精简 CHANGELOG 历史版本记录\n\n### 其他\n\n- **新增 Serena MCP 配置与项目记忆** - 新增 Serena MCP 配置与项目记忆\n- **升级 Vite 和 Vitest** - 前端升级 Vite 和 Vitest 依赖\n- **合并 guide/ 重定向文件修复** - 合并 guide/ 重定向文件修复分支\n\n## [v2.8.24] - 2026-06-04\n\n### 修复\n\n- **修复深层嵌套图片检测遗漏导致视觉回退失效** - 修复嵌套结构中图片 URL 未被识别,导致视觉回退策略无法生效的问题\n\n## [v2.8.23] - 2026-06-04\n\n## [v2.8.22] - 2026-06-04\n\n### 修复\n\n- **修复桌面端管理面板 CORS 认证失败** - 修复 `OPTIONS` 预检请求被认证中间件拦截返回 401,导致桌面端 WebView 无法访问管理接口的问题;`ENABLE_CORS=false` 时仍为带 `Origin` 的请求注入 CORS 响应头\n- **version-bump 新增 CHANGELOG 覆盖率校验** - 版本升级流程新增 CHANGELOG 覆盖率校验,防止遗漏提交记录\n\n### 文档\n\n- **补全 v2.8.21 CHANGELOG** - 补全 v2.8.21 版本遗漏的 CHANGELOG 条目\n- **新增 Windows 桌面图标透明度待办项** - 新增 Windows 桌面图标透明度相关的待办任务\n\n## [v2.8.21] - 2026-06-04\n\n### 新增\n\n- **桌面端原生管理控制台** - 桌面端原生管理控制台替代 iframe WebUI,完整覆盖 i18n,管理入口统一命名为 Dashboard\n- **桌面端渠道管理重构** - 添加渠道简化为快速粘贴模式(支持 Cmd/Ctrl+Enter 创建),编辑渠道弹窗对齐 WebUI 核心交互,新增预设模板、模型拉取、高级选项分组、Base URL 预览、服务类型动态排序、模型映射协议预置列表与 filter chips、API Key 必填校验\n- **桌面端渠道操作增强** - 渠道卡片支持置顶置底并增加删除守卫,渠道日志弹窗重构为可展开列表并支持自动刷新,删除渠道时显示 loading 遮罩,模型重定向 target 输入框始终提供候选\n- **桌面端会话驾驶舱重构** - 会话驾驶舱重构为瀑布流卡片并对齐 WebUI cockpit,能力测试对话框对齐 WebUI,能力测试多 job 并发轮询修复\n- **桌面端 Codex 会话迁移** - 添加 Codex 会话迁移支持,快捷/插件模式切换对齐 WebUI\n- **CLI --backupdir 选项** - 新增 `--backupdir` 选项指定配置备份目录\n- **CLI 自定义运行时路径** - 支持自定义运行时路径\n- **LOG_DIR=none 禁用日志文件** - 支持 `LOG_DIR=none` 环境变量禁用日志文件写入\n- **渠道级请求超时配置** - 后端渠道配置新增 `requestTimeoutMs`,非流式上游请求优先使用渠道超时,未设置或为 0 时继承全局 `REQUEST_TIMEOUT`\n\n### 修复\n\n- **messages**: system 角色归一化对所有上游生效\n- **日志 store 改用 metricsKey 分桶** - 根治删除渠道后日志串桶问题\n- **模型过滤拒绝非法字符** - 模型过滤拒绝顿号等非法字符并按分隔符拆分\n- **余额不足/套餐过期自动拉黑** - 补全 403 关键词自动拉黑,改为双词组合匹配覆盖更多上游变体\n- **下架已停用 GPT 模型** - 从能力测试候选、模型重定向候选和预设中移除 `gpt-5.2` / `gpt-5.2-codex` / `gpt-5.3-codex`,haiku 映射改为 `gpt-5.4-mini`\n- **桌面端编辑渠道大量 UI 修复** - 快捷键与底部操作栏、Esc/Enter 快捷键、窄窗口布局、高级选项平铺 Runtime/Transport 分组、Website 移入连接分组、模型卡片布局、noVisionModels 合并行级 toggle、思考强度/Text verbosity 下拉修复、popover 主题变量补全、移除冗余 Latency 列与重复快捷键提示、保存快捷键统一为 Cmd/Ctrl+Enter\n- **桌面端快速粘贴优化** - Textarea 高度修正、自动应用识别结果、自动补全 name/serviceType、按钮提示显示平台对应修饰符、移除 Service Type 识别卡片\n- **桌面端 Dashboard 修复** - 移除轮询可见性检查修复渠道列表不自动刷新、管理面板 Tabs 配色对齐深色主题、备用池文案对齐 WebUI、驾驶舱命名对齐并移除手动刷新、网关监控按钮改为「打开管理面板」\n- **桌面端模型列表修复** - 拉取补传 key 参数修复 'No API key provided' 错误、源模型列表对齐 WebUI allSourceModelOptions、预设数据与按钮分组完全对齐 WebUI\n- **桌面端移除一键配置已下线模板** - 移除 gpt-5.3/gpt-5.2 模板,haiku 改映射到 gpt-5.4-mini\n- **桌面端渠道中心恢复原有 ChannelTab 界面** - 移除渠道测速按钮,补齐编辑弹窗快捷键\n- **桌面端 i18n 补全** - 编辑渠道界面、编辑弹窗新增 i18n keys 等缺失翻译补全\n\n### 重构\n\n- **桌面端管理入口命名统一** - 管理入口统一命名为 Dashboard,隐藏批量测速按钮,移除 Codex 分组冗余开关\n- **桌面端编辑渠道布局对齐** - 请求超时从基础信息移入 Runtime 分组,抽取渠道草稿保存逻辑统一提交路径\n- **渠道管理清理** - 移除未使用代码(开启 noUnusedLocals)、删除渠道编辑弹窗冗余元素\n\n### 文档\n\n- **Docker 部署示例修正** - 修正 Docker 部署示例与实际 compose 保持一致\n- **LOG_DIR=none 补充文档** - 补充 `LOG_DIR=none` 禁用日志文件文档及测试用例\n- **新增 TODO 待办任务文档**\n\n## [v2.8.20] - 2026-06-01\n\n### 新增\n\n- **渠道级请求超时配置** - 后端渠道配置新增 `requestTimeoutMs`,非流式上游请求优先使用渠道超时,未设置或为 0 时继承全局 `REQUEST_TIMEOUT`;五类渠道新增/更新路径均校验负数并支持清空回退。\n- **Web UI 渠道请求超时表单** - 渠道编辑表单新增“请求超时 (ms)”输入项,支持编辑态清空恢复继承全局,并在复制渠道、payload 序列化、差异检测和多语言文案中同步该字段。\n\n### 新增\n\n- **桌面端 Codex Agent 配置双模式** - Codex 支持两种 CCX 模式:快捷模式(OpenAI provider + CCX 代理,不迁移会话、不支持插件)和插件模式(CCX 原生 provider + `requires_openai_auth = true`,支持插件但切换需会话迁移提示)。UI 下拉框拆分为两个选项,互切时显示会话迁移警告。\n- **Codex 配置改用快捷/插件模式开关** - 桌面端 Codex 配置入口统一改为快捷模式/插件模式开关,明确不同接入路径。\n- **Codex provider 块统一使用 `env_key`** - 将 `[model_providers.xxx]` 块中的 `temp_env_key` 统一替换为官方文档字段 `env_key`。\n- **桌面端 AgentCard 编辑器打开支持多编辑器选择** - 配置文件和认证文件的\"用编辑器打开\"按钮现在支持多编辑器场景,通过透明 select 覆盖层提供编辑器选择下拉。\n- **渠道中心已保存 key 的 preset 优先排列** - 桌面端渠道中心将已保存 API Key 的 preset 优先展示,便于快速选择常用渠道。\n- **已配置 key 的渠道在渠道中心优先展示** - Web 前端渠道中心根据已配置 Key 状态优先排列渠道。\n- **Codex 渠道模板新增 codex-auto-review 重定向** - 渠道模板新增 codex-auto-review 重定向能力。\n- **新增 WebUI 新用户指引** - WebUI 增加新用户引导说明,帮助首次使用者快速理解渠道配置流程。\n- **OpenAI 直连增加 API Key 勾选框** - 桌面端 OpenAI 直连新增「我有自己的 API Key」勾选框。\n- **新用户指引添加渠道展示模拟表单** - 新用户指引增加渠道展示模拟表单,更直观展示快速模式字段。\n- **Codex 第三方 provider 支持快捷/插件模式切换** - 第三方 Codex provider 支持在快捷模式与插件模式之间切换。\n\n### 修复\n\n- **下架已停用 GPT 模型** - 从能力测试候选、模型重定向候选和预设中移除 `gpt-5.2` / `gpt-5.2-codex` / `gpt-5.3-codex`,haiku 层模型统一替换为 `gpt-5.4-mini`,并为桌面端渠道模板补充旧模型回归检查。\n\n- **浏览器直达移除 `ccx_desktop` 参数** - 修复浏览器直达 WebUI 时残留桌面端参数的问题。\n- **默认语言改为中文并增加浏览器语言检测** - 修复默认语言选择逻辑,新增浏览器语言检测。\n- **新用户指引协议切换说明拆分为三段** - 调整新用户指引中协议切换说明的展示结构。\n- **新用户指引简化标签列表** - 将新用户指引标签列表简化为 Claude / Codex / Gemini 等。\n- **新用户指引示例渠道改为 gpt-5.5 中转** - 更新新用户指引中的示例渠道文案。\n- **新用户指引删除 quickHint** - 删除 quickHint,改为说明 CCX 自动识别。\n- **删除新用户指引模拟表单及关联资源** - 删除模拟表单及其关联 i18n/CSS 资源,简化引导内容。\n- **新用户指引添加渠道说明拆分为两段** - 调整添加渠道说明,使内容层次更清晰。\n- **渠道置顶后保持渠道顺序** - 修复渠道置顶后顺序被打乱的问题。\n- **修正 Codex 认证配置模式** - 修复 Codex 认证配置模式写入与展示不一致的问题。\n- **渠道排序以 priority 优先,hasKey 仅作兜底** - 修复渠道排序中 hasKey 权重过高导致 priority 失效的问题。\n- **消除 Codex 非 OpenAI provider 时悬空分隔符** - 修复 Codex 非 OpenAI provider 表单中多余竖线分隔符。\n- **调整 Linux 数据目录到 XDG state** - 修复 Linux 桌面端数据目录位置,改用 XDG state 路径。\n- **掩码 Codex 配置预览密钥** - 修复 Codex 配置预览中密钥未正确脱敏的问题。\n- **修正 Codex `auth_mode` 为 `apikey`** - 修正 Codex 配置中的认证模式字段值。\n- **OpenAI 直连取消勾选 API Key 时清空残留值** - 修复 OpenAI 直连关闭自带 Key 后仍保留旧值的问题。\n- **修正 Codex 配置预览 `auth_mode` 被误掩码** - 修复配置预览中普通字段被误判为密钥的问题。\n- **对齐 Codex 插件模式认证配置** - 修复 Codex 插件模式下认证配置与预期不一致的问题。\n- **JSON 配置 diff 改为字段级掩码** - 修复 JSON 配置 diff 脱敏粒度过粗导致预览不准确的问题。\n- **修正短密钥掩码后 JSON diff 漏报变更** - 修复短密钥脱敏后 JSON diff 无法识别真实变更的问题。\n- **按字段掩码 Codex 配置 diff** - Codex 配置 diff 改为按字段掩码,避免误掩码普通配置项。\n- **OpenAI 直连自带 Key 必须输入** - 修复 OpenAI 直连勾选自带 Key 时允许空值的问题。\n\n### 重构\n\n- **移除左上角 Logo 点击跳转 GitHub 链接** - 移除 UI 左上角 Logo 的 GitHub 跳转行为。\n- **配置预览文件标签改用增删行数统计** - 桌面端配置预览文件标签改为展示增删行数统计。\n\n### 文档\n\n- **更新桌面安装文档** - 更新桌面安装文档,补充 Windows Store 已上架和 Homebrew 安装说明。\n- **排障指南添加各入口 curl 快速验证命令** - 为排障指南补充各协议入口的 curl 快速验证命令。\n- **排障指南首条添加关闭全局代理和 TUN** - 排障指南首条新增关闭全局代理和 TUN 的排查建议。\n\n### 其他\n\n- **修复 go fmt 格式化缩进** - 修正 Go 格式化后的缩进样式。\n- **移除 Claude-Reasoning-Debug 调试日志** - 移除 Claude-Reasoning-Debug 调试日志及 summarizeAssistantReasoningState 函数。\n- **精简新用户指引内容** - 移除新用户指引中的状态图例、开关和接入客户端三节。\n- **补充 desktop type-check 脚本** - 为桌面前端补充 type-check 脚本。\n- **固化 Wails3 CLI 安装版本** - CI 中固定 Wails3 CLI 安装版本,提升构建稳定性。\n\n## [v2.8.19] - 2026-05-30\n\n### 修复\n\n- **补全 MiMo target 描述删除遗漏** - 补全桌面端 MiMo target 描述删除时的遗漏处理。\n\n## [v2.8.18] - 2026-05-30\n\n### 新增\n\n- **按 ReasoningParamStyle 转换客户端原始 reasoning 参数** - 新增按 ReasoningParamStyle 配置项转换客户端原始 reasoning 参数的能力。\n- **Passthrough 路径 ReasoningMapping 分支补充 reasoning_effort 样式处理** - 在 passthrough 路径的 ReasoningMapping 分支中补充 reasoning_effort 样式的处理逻辑。\n- **桌面端 add Homebrew tap 自动化** - 新增 Homebrew tap 自动化发布流程。\n- **桌面端点击版本号触发手动更新检查并计入冷却时间** - 点击版本号可手动触发更新检查,并计入冷却时间避免频繁检查。\n- **桌面端 Codex CCX 代理切换为 openai_base_url 官方模式** - Codex CCX 代理从自定义模式切换为 openai_base_url 官方模式。\n\n### 修复\n\n- **渠道中心布局断点降低至 md 并修复窄屏 URL 溢出** - 修复渠道中心在窄屏下的布局问题,将断点降低至 md 并修复 URL 文本溢出。\n- **Responses API 返回 426 让 Codex WebSocket 回退到 HTTP POST** - 修复 Responses API 返回 426 状态码时 Codex WebSocket 正确回退到 HTTP POST。\n- **修复断裂文档链接,统一 PORT=3688,补充源码构建前置依赖** - 修复文档中断裂的链接,统一 PORT 配置为 3688,补充源码构建的前置依赖说明。\n- **MiMo messages 目标描述统一为通用表述** - 统一桌面端 MiMo messages 渠道目标描述为通用表述。\n\n### 重构\n\n- **移除熔断器弹窗标题图标** - 移除熔断器弹窗中的标题图标,简化界面。\n- **渠道中心添加目标标签改为产品名称** - 将渠道中心的添加目标标签改为显示产品名称。\n\n### 其他\n\n- **UI 熔断器配置改为预设按钮+滑块交互** - 将熔断器配置改为预设按钮+滑块的交互方式,删除重复的 .env 分组。\n- **UI 熔断器参数三列并排布局+间隔线** - 调整熔断器参数为三列并排布局并增加间隔线。\n- **UI 预设按钮移到滑块下方,参数列增加内边距** - 将预设按钮移到滑块下方,参数列增加内边距优化间距。\n\n## [v2.8.17] - 2026-05-29\n\n### 新增\n\n- **新增 gpt-5.4-mini 到 codex responses 协议源模型列表** - 前端 codex responses 协议源模型列表新增 gpt-5.4-mini 模型。\n- **更新 Gemini 源模型列表** - 更新前端 Gemini 源模型列表配置。\n- **更新模型列表并同步前后端** - 更新模型列表并保持前后端配置同步。\n\n### 修复\n\n- **WebUI iframe 认证使用 ADMIN_ACCESS_KEY 而非 PROXY_ACCESS_KEY** - 修复桌面端 WebUI iframe 认证使用错误密钥的问题。\n\n### 其他\n\n- **简化 favicon,替换为极简 CX 描边图标** - 简化 favicon 设计,替换为极简 CX 描边图标。\n\n## [v2.8.16] - 2026-05-29\n\n### 新增\n\n- **添加 DashScope Token Plan 预设** - 桌面端添加 DashScope Token Plan 预设。\n\n### 修复\n\n- **修正渠道一键预设显示** - 修正 UI 渠道一键预设显示问题。\n- **复用 Windows 安装器旧安装目录** - 桌面端 Windows 安装器复用旧安装目录。\n- **修正 Web Logo 主题配色** - 修正 Web Logo 在不同主题下的配色显示。\n- **修正暗色模式 Logo 配色** - 修正桌面端暗色模式下 Logo 配色。\n- **修正图标亮暗主题显示** - 修正 UI 图标在亮暗主题下的显示。\n- **隐藏桌面 WebUI 版本检测** - 隐藏桌面端内嵌 WebUI 版本检测。\n\n### 其他\n\n- **隔离 backend 端口选择测试避免读取真实 .env** - 隔离桌面端 backend 端口选择测试,避免读取真实 `.env`。\n\n## [v2.8.15] - 2026-05-29\n\n### 新增\n\n- **重设计应用图标与品牌 Logo** - 桌面端应用图标与品牌 Logo 全新设计。\n\n### 修复\n\n- **统一 BuildChannelView 高级开关字段输出** - 将 `normalizeSystemRoleToTopLevel`、`stripEmptyTextBlocks`、`passbackReasoningContent`、`passbackThinkingBlocks`、`injectDummyThoughtSignature`、`stripThoughtSignature` 六个高级开关字段统一收归 `BuildChannelView()` 公共函数输出,消除 messages/chat/responses/gemini 四个 handler 中的重复补丁,确保所有渠道类型的 WebUI 编辑回显与保存一致。\n- **移除 PreviewAgentConfigDiff 未使用的 status 变量** - 清理桌面端 PreviewAgentConfigDiff 组件中未使用的 status 变量。\n- **Agent 配置应用使用最新 .env 端口** - 桌面端 Agent 配置应用时使用最新的 .env 端口值。\n- **统一 Web UI Logo 与应用图标** - 统一 Web UI 中的 Logo 和应用图标,保持视觉一致性。\n- **补齐渠道一键模板配置** - 前端补齐渠道一键模板配置功能。\n- **调整 Opus 4.8 能力探测请求** - 调整 Claude Opus 4.8 模型的能力探测请求参数。\n- **修复预设渠道控制台网址和套餐显示** - 修复预设渠道中控制台网址和套餐信息显示不正确的问题。\n\n## [v2.8.14] - 2026-05-29\n\n### 新增\n\n- **新增 normalizeSystemRoleToTopLevel 开关兼容旧 Claude 上游** - 新增系统角色规范化到顶层字段的兼容开关,便于旧版 Claude 上游适配。\n- **统一桌面端与 Web UI 的 MiMo/DeepSeek 渠道预设配置** - 统一桌面端与 Web UI 的 MiMo、DeepSeek 渠道预设,保持跨入口配置一致。\n- **补全渠道中心全部预设的中英文 i18n** - 为渠道中心全部预设补齐中英文国际化文案。\n- **主题切换改为自动/亮色/暗色三按钮,默认跟随系统** - 桌面端主题切换调整为自动、亮色、暗色三按钮,并默认跟随系统主题。\n\n### 修复\n\n- **修复 Messages 嵌套工具结果图片未触发视觉回退** - 识别 `tool_result.content` 等嵌套 content 数组中的图片块,确保 MiMo 等配置 `visionFallbackModel` 的渠道在历史截图请求中正确切换到识图回退模型,并跳过 `noVision` 渠道。\n- **调整主题选项排序** - 调整桌面端主题选项展示顺序,使默认与常用选项更符合使用习惯。\n- **修复中文模式下渠道说明显示英文** - 修复中文界面中渠道说明仍显示英文的问题。\n- **修复渠道 index 为 0 时能力测试无法启动** - 修复前端能力测试在渠道 index 为 0 时被误判而无法启动的问题。\n- **修复桌面端内嵌 Web UI 能力测试无响应的问题** - 修复桌面端内嵌 Web UI 发起能力测试后无响应的问题。\n- **修复亮色模式下所有状态色文字对比度不足** - 调整亮色模式状态色文本样式,提升可读性。\n\n### 重构\n\n- **移除浏览器模式的 Wails mock 传输层** - 移除桌面端浏览器模式下的 Wails mock 传输层,简化开发模式通信链路。\n\n## [v2.8.13] - 2026-05-29\n\n### 新增\n\n- **添加 claude-opus-4-8 模型到能力探测与优先级列表** - 前端能力探测与模型优先级排序规则新增 claude-opus-4-8 模型支持。\n- **桌面端亮暗色主题切换** - 桌面端新增亮色/暗色主题切换功能,支持用户自定义界面外观。\n\n### 其他\n\n- **桌面端 golang.org/x/sys 提升为直接依赖** - 将 `golang.org/x/sys` 从间接依赖提升为直接依赖,确保依赖关系清晰。\n\n## [v2.8.12] - 2026-05-28\n\n### 修复\n\n- **Linux 桌面构建依赖与运行环境更新** - 更新 CI 中 Linux 桌面构建的系统依赖为 gtk4 与 webkitgtk-6.0,构建运行环境切换至 ubuntu-24.04,确保桌面端在最新 Linux 发行版上正常编译与运行。\n\n## [v2.8.11] - 2026-05-28\n\n### 新增\n\n- **桌面端浏览器直连 Vite dev 端口可走通初始化** - 开发模式下支持浏览器直接连接 Vite dev 端口完成初始化流程(mock Wails IPC),无需通过 Wails 桌面窗口即可调试前端。\n\n### 修复\n\n- **WebUI 快速添加渠道不再误解析配置键** - 修正输入解析,避免 `api_key:`、`url:` 等字段名被识别为 API Key;解析逻辑改为保护 URL 中的冒号,并增加常见配置键名白名单过滤。\n- **WebUI 快速添加渠道后新渠道始终置顶** - 修正快速添加流程中渠道定位方式,不再依赖 \"index 最大\" 的启发式;改为按渠道名称精确匹配新渠道,确保新渠道保持在第一位并触发促销期。\n- **Windows 首次启动语言检测按平台分支** - 桌面端分平台检测系统语言,修复 Windows 首次启动时语言显示为英文的问题。\n- **Linux AppImage 使用 Ubuntu 22.04 构建以兼容 GLIBC 2.35** - CI 构建环境从新版 Ubuntu 降级到 22.04,确保 AppImage 在较旧 Linux 发行版上正常运行。\n\n### 文档\n\n- **推荐下载卡片增加 gh-proxy 加速下载链接** - 为下载卡片添加 gh-proxy 加速镜像链接,提升国内用户下载体验。\n- **站点 hero 区域单行标题与 tagline 扩展** - hero 区域改为单行标题、tagline 扩展上游列表与 env 代码块高亮。\n\n## [v2.8.10] - 2026-05-28\n\n### 新增\n\n- **桌面端多语言切换** - 新增轻量 i18n 框架(zh-CN / en),默认英文并自动跟随系统语言,首次启动按系统语言选择、手动切换后持久化到 `ui-preferences.json`;DesktopService 暴露 `GetLanguagePreference` / `SaveLanguagePreference`;侧栏底部守护面板新增语言切换入口;Sidebar、Setup、Status(含 LogViewer、DiagnosticCard)、Agent(含 ProviderForm、ConfigDiffDialog)、Channel、Env、WebUI 等用户可见文案全部迁移到 `t()`,便于 Microsoft Store Windows 截图审核。\n- **桌面端 Agent 配置支持 OpenCode** - configservice 新增 `PlatformOpenCode` 平台、`OpenCodeProxyState`、JSONC provider 块解析与 `auth.json` 读写;默认 ccx provider 走 `http://127.0.0.1:/v1`,可选直连 DeepSeek/MiMo/Compshare/Kimi/GLM/MiniMax/DashScope/OpenCode Zen/Go;写入 `~/.config/opencode/opencode.jsonc` 与 `~/.local/share/opencode/auth.json`;前端 AgentTab/AgentCard/ConfigDiffDialog 渲染 OpenCode 卡片;同步补充中英文 OpenCode 客户端 Desktop 自动配置文档。\n- **静默版本检查替代 wails updater 自动弹窗** - 关闭 wails updater 30 分钟自动 `CheckAndInstall`(无差别弹窗,失败场景尤为干扰);新增 `desktopservice.CheckLatestRelease(force)` 直查 GitHub Releases,过滤 prerelease,服务端缓存 4 小时(错误 30 分钟);侧栏版本号旁追加「新版 vX.X.X」胶囊,仅有更新时显示,点击打开 release 页(Store 分发不显示);抽出 `useReleaseCheck` composable(启动 8s 首次检查、之后 4 小时一次);托盘菜单「检查更新…」入口保留,主动点击仍走完整 wails updater 安装流程。\n- **OpenCode 直连仅保留官方原生支持的国产渠道** - OpenCode 卡片下拉移除 MiMo/Compshare/DashScope(OpenCode 官方未原生支持),保留 ccx + DeepSeek/Kimi/GLM/MiniMax + Zen/Go;`useAgentConfig` 同步补齐对应 baseURL 分支。\n- **渠道中心 preset 文案 i18n 化并区分 plan 渠道名** - 新增 `translateOrFallback` / `tf` helper,preset/plan/target 文案按 key 翻译、缺失 key 回退到 Go preset 原文;新增 `preset-messages.ts` 仅维护 EN,zh-CN 复用后端中文;channelName 默认值在选中非默认 plan 时追加 plan suffix,避免 MiMo/DashScope 多套餐同名覆盖。\n\n### 修复\n\n- **/v1 等裸 API 前缀不再被 SPA 兜底成 WebUI** - `isAPIPath` 仅匹配带尾部斜杠的前缀(\"/v1/\" 等),导致 `GET /v1`、`GET /api`、`GET /admin` 这类裸前缀落到 NoRoute 的 SPA 兜底分支返回 `index.html`;改为同时匹配 `path == prefix` 与 `prefix+\"/\"`,并补上之前漏掉的 `/v1beta`(Gemini 原生格式);新增表驱动测试覆盖裸前缀、子路径以及 `/v1custom` / `/apifoo` 等非 API 路径。\n- **状态页 ServiceDetails 去除与 Sidebar 重叠的 PID/Health 信息** - 移除卡片内 PID 与健康状态行(Sidebar 已展示),同时去掉冗余小标题,避免与左侧栏视觉重叠。\n- **统一 Agent 卡片 Provider 下拉文案至 i18n** - Codex 卡片 ccx 选项原使用 `agent.localGateway`(\"当前 CCX 网关\"),与 Claude/OpenCode 卡片的 `agent.provider.localGateway`(\"CCX 本地网关\")不一致;OpenCode 卡片其余选项及\"访问官方控制台\"按钮、API Key placeholder 也是硬编码中文;统一改用 `agent.provider.*` 与 `agent.openConsole` i18n key。\n- **注册 mdi-head-snowflake 图标** - AddChannelModal 的\"回传 Thinking Blocks\"开关使用 `mdi-head-snowflake`,但 `vuetify.ts` 未导入,运行时图标回退到占位文本;补 `@mdi/js` 导入与 `iconMap` 映射。\n\n### 优化\n\n- **收紧 ServiceDetails 卡片上下内边距** - 桌面端状态页 ServiceDetails 卡片上下内边距收紧,提升信息密度。\n- **语言切换合入侧栏底部守护面板** - 桌面端语言切换入口整合到侧栏底部守护面板,与现有偏好控件统一布局。\n\n### 文档\n\n- **新增桌面端教程截图** - 为桌面端使用指南补充压缩后的 CCX Desktop 截图,并将端口引用对齐到默认 3688。\n\n## [v2.8.9] - 2026-05-27\n\n### 修复\n\n- **MiMo 计费目标 URL 不更新** - 修复桌面端 MiMo 计费配置切换后目标 URL 未正确更新的问题。\n- **Claude 渠道思考块透传缺失** - 修复 Claude 渠道返回 reasoning/thinking blocks 时未按配置回传的问题。\n- **Responses 透传渠道预设误展示 gpt-5.x 一键设置** - Responses 协议透传渠道隐藏不适用的 gpt-5.x 一键预设,避免误配置。\n- **导引完成后默认页签错误** - 桌面端首次导引完成后默认跳转 Agent 配置,而非渠道中心。\n- **Sidebar 导入路径错误** - 修复桌面端 `Sidebar.vue` 绑定导入路径使用 `@/bindings` 导致的问题,改为正确的 `@bindings`。\n- **Responses 到 Claude 缺少 max_tokens 默认值** - 修复 Responses → Claude 链路缺少 `max_tokens` 默认值的问题。(#133)\n\n### 其他\n\n- **补充 max_tokens 边界测试** - 为 Responses → Claude 转换链路补充 `max_tokens` 边界测试,覆盖默认值与异常边界。\n\n## [v2.8.8] - 2026-05-27\n\n### 新增\n\n- **DashScope Coding Plan 选项** - 桌面端 DashScope provider 新增 Coding Plan 计费选项,Claude 直连 Agent 配置支持 Coding Plan 选择器。\n\n### 变更\n\n- **桌面端自更新从手写 updater 改为 Wails v3 内置 Updater** - 删除 `desktop/internal/updater/` 包(含 macOS DMG 挂载、Windows NSIS 安装器、Linux AppImage nohup 替换等平台安装器,行为模式易被杀软误杀);移除 `CheckUpdate`/`DownloadAndInstall`/`CancelUpdate` Go 导出方法和 `UpdateInfo` 结构体;改用 Wails v3 `pkg/updater`(GitHub Releases Provider + SHA256SUMS 校验 + 内置更新 UI 窗口 + 30 分钟定时检查);删除前端 `useUpdater.ts` composable 和 `UpdateDialog.vue` 组件,Sidebar 版本按钮改为纯展示(更新检查通过托盘菜单触发);Wails v3 依赖从 alpha.95 升级到 alpha.96\n- **DashScope 按量付费计划标签对齐 MiMo 规范** - 统一 DashScope 渠道预设的按量付费计划标签显示,与 MiMo 规范保持一致。\n\n### 修复\n\n- **Chat 渠道选择 Responses 上游时请求格式与端点错误** - 修复 Chat 渠道 `serviceType: \"responses\"` 时仍以 Chat 格式发送到 `/v1/chat/completions` 的问题;新增 Chat↔Responses 双向协议转换(请求:messages→input, max_tokens→max_output_tokens;响应:output→choices),流式 SSE 事件转换(response.output_text.delta→Chat chunk),前端预期请求 URL 同步修正为 `/v1/responses`。(#130)\n- **规范化 role 开关提示不够明确** - 明确 i18n 中规范化 role 开关的提示文案,注明 developer 角色转换与国内模型建议。\n\n### 文档\n\n- **添加英文 CCX Desktop 指南和入口链接** - 新增英文版 CCX 桌面端使用指南与文档入口链接。\n\n## [v2.8.7] - 2026-05-27\n\n### 文档\n\n- **完善桌面端用户教程与客户端接入入口** - 补充桌面端使用教程与客户端接入文档入口说明。\n\n### 重构\n\n- **MiMo 渠道预设移除自定义 base URL,优化 token plan 切换与下拉标签** - MiMo 渠道预设不再设置自定义 base URL,同时优化 token plan 切换逻辑与下拉标签显示。\n- **go fmt 格式化 channelpreset 缩进对齐** - 对桌面端 channelpreset 模块执行 go fmt 统一格式化。\n\n### 修复\n\n- **含图请求不再覆盖普通文本 Trace 亲和** - 含图请求 failover 到视觉渠道成功后,不再改写同一用户的普通文本 Trace 亲和,避免 DeepSeek 等文本渠道被长时间绕过;`HasImageContentCached` 只读缓存,不影响已有 Images 渠道行为。\n- **恢复 BuildPayload Description 写入,移除 DMG 打包错误静默** - 恢复桌面端 BuildPayload 中 Description 字段的写入,移除 DMG 打包流程中对错误的静默处理以便排查问题,同时格式化相关测试文件。\n\n### 其他\n\n- **补充 NoVisionModels 断言覆盖** - 为桌面端 NoVisionModels 相关逻辑补充断言覆盖,增强测试完整性。\n\n## [v2.8.6] - 2026-05-26\n\n### 新增\n\n- **Images 渠道内置模型列表新增 gpt-image-2** - 前端 Images 渠道的内置模型选项新增 `gpt-image-2`。\n- **渠道中心 provider 官方控制台链接** - 桌面端渠道中心为各 provider 添加官方控制台访问链接,方便快速跳转管理;badges 与链接布局调整至标题同行,避免错位。\n- **macOS DMG 安装包自定义拖拽背景** - 新增自定义 DMG 背景图与安装布局,Applications 快捷方式与 .app 拖拽区对齐 FlyShadow 风格。\n\n### 变更\n\n- **Compshare 渠道预设调整** - 高级模型重定向(opus/sonnet/gpt-5.4)从 deepseek-v4-pro 改为 glm-5.1;Chat/Responses 渠道移除内置模型重定向(用户自行在客户端指定模型);视觉策略从全渠道 NoVision 改为仅 deepseek-v4-flash 关闭视觉,并设置 MiniMax-M2.7 为视觉回退模型。\n\n### 修复\n\n- **添加渠道不写入描述,新渠道插入列表首位** - 修复添加渠道时描述字段未写入的问题,新增渠道现在默认插入列表首位而非末尾。\n- **渠道预设视觉与重定向策略重构** - 统一桌面端渠道预设的视觉(NoVision)与模型重定向配置,修正多个预设中不一致的策略。\n\n## [v2.8.5] - 2026-05-26\n\n### 新增\n\n- **桌面端 Store MSIX 分发支持** - Windows 发布流程新增 Store MSIX 分发配置与打包脚本,为 Microsoft Store 分发补齐 manifest、package 脚本和 release 产物链路。\n- **桌面端 Compshare 推广入口** - 桌面端新增 Compshare 推广入口与外链打开能力,便于用户从侧边栏访问相关服务。\n- **Compshare Agent 直连支持** - 桌面端 Agent 配置新增 Compshare provider 选项,并同步支持直连配置状态识别与应用。\n- **Compshare 渠道预设** - 渠道中心新增 Compshare 预设,补充对应模型映射、服务类型和预设测试覆盖。\n\n### 文档\n\n- **隐私政策说明** - 新增中英文隐私政策页面,并将其接入文档导航。\n- **首页下载推荐展示优化** - 调整 README 首页下载推荐展示,并补充 VitePress 自适应下载推荐组件。\n- **首页自适应下载推荐** - 文档首页新增根据平台自适应展示推荐下载项的能力。\n\n### 其他\n\n- **合并 Compshare 桌面渠道支持** - 合并 Compshare 桌面渠道相关功能分支,纳入本次发布范围。\n\n## [v2.8.4] - 2026-05-25\n\n### 修复\n\n- **桌面端自动更新前停止后端进程** - 自动更新下载并校验安装包后,会先停止由桌面壳托管的 Go 后端,再启动平台安装器;如果当前后端由外部进程托管或停止失败,则中止更新并提示用户手动停止后重试,避免 Windows 安装时后端二进制被占用导致只更新 UI。\n\n## [v2.8.3] - 2026-05-25\n\n### 修复\n\n- **桌面端重启 CCX 服务后误报启动失败** - 网关监控重启服务时,停止与启动阶段改用独立超时上下文,避免停止耗时挤占启动健康检查时间;同时健康检查成功后清理过期错误状态,避免日志显示已重启成功但状态卡片仍展示启动失败。\n- **静默 health 轮询访问日志** - health 检查轮询现在遵循静默轮询日志配置,避免高频健康检查污染访问日志。\n- **桌面端窗口拖动时背景闪烁** - 桌面端窗口背板改用不透明背景,降低拖动窗口时的透明闪烁感。\n- **Logo 默认动画导致界面闪烁** - 关闭 Logo 默认动画,避免页面初始渲染和切换时出现不必要的视觉闪烁。\n- **Responses 单渠道模式未追踪对话数据** - 修复 Responses 单渠道成功请求未写入对话追踪器的问题,使驾驶舱能够正确展示单渠道对话数据。\n\n### 其他\n\n- **补充 Responses 单渠道对话追踪回归测试** - 为 Responses 单渠道成功请求新增 handler 级测试,验证请求会写入 `ConversationTracker` 并生成驾驶舱对话记录,防止 `/conversations` 再次为空。\n\n## [v2.8.2] - 2026-05-25\n\n### 新增\n\n- **桌面端最小化后隐藏 macOS Dock 图标** - macOS 下窗口最小化后同步隐藏 Dock 图标,减少后台运行时的 Dock 干扰。\n- **桌面端路径项支持打开目录/编辑器操作** - Agent 相关路径展示新增纯图标操作按钮,可直接打开目录或用编辑器打开配置文件。\n\n### 修复\n\n- **桌面端渠道预设模型重定向键统一为 `gpt-5`** - MiMo、MiniMax、OpenCode Zen、OpenCode Go 的 Chat / Codex Responses 单模型重定向现在统一使用 `gpt-5` 作为源模型键,匹配 Codex / Chat 客户端实际请求模型;同时集中整理渠道预设的模型映射、reasoning 映射和 Codex 兼容开关配置。\n- **桌面端中文系统默认语言仍为英文** - 环境配置表单在 `.env` 缺少 `APP_UI_LANGUAGE` 时会读取 WebView 暴露的系统/浏览器语言,中文系统默认填入 `zh-CN`,非中文系统继续默认 `en`,且不会覆盖用户已有 `.env` 设置。\n- **桌面端 MiniMax 渠道预设与 Codex 兼容配置不完整** - MiniMax Responses 预设现在使用 `gpt-5 -> MiniMax-M2.7` 模型映射,并启用非标准 Chat 角色归一化;同步补充渠道预设测试对归一化字段的断言。\n- **桌面端 Codex 应用 CCX 渠道时 auth 密钥不一致** - Codex Agent 配置现在优先读取桌面 dataDir、项目根目录和 `backend-go` 下 `.env` 中的 `PROXY_ACCESS_KEY`,再回退到进程环境变量或自动生成值,避免 `~/.codex/auth.json` 写入与实际后端配置不一致的密钥;同时增强 `.env` 解析以兼容 `export PROXY_ACCESS_KEY=...` 和等号周围空格。\n- **能力测试复制到其他 Tab 时原生协议类型错误** - 当能力测试中某个原生协议测试成功后复制渠道到其他协议 Tab 时,新增渠道的 `serviceType` 现在按成功协议映射为对应原生上游类型(Claude Messages / OpenAI Chat / Gemini / Responses),不再沿用源渠道的协议类型;转换按钮同时保留目标 Tab 与成功协议两个语义,避免复制到目标 Tab 时写入错误上游类型。\n- **桌面端初始配置完成状态误读环境变量** - `IsSetupComplete` 现在只读取桌面 dataDir 下的 `.env`,避免进程环境变量或项目根目录 `.env` 让首次启动引导被错误跳过,并补充对应单元测试。\n- **桌面端 Agent 状态路径展示溢出** - Agent 卡片中的当前/目标 URL、配置文件和认证文件路径改为固定标签列 + 可换行内容列,保留编辑器打开按钮且避免长路径挤压布局。\n- **桌面端网关监控详情字段未对齐** - 网关监控详情展示字段与后端服务状态字段保持一致,避免状态信息展示不完整或错位。\n- **桌面端托盘隐藏 Dock 图标时崩溃** - 修复托盘状态下触发隐藏 Dock 图标可能导致桌面端崩溃的问题。\n- **桌面端渠道预设刷新按钮点击报错** - 移除无效的刷新预设按钮,避免点击后触发前端异常。\n- **桌面端页面内容区宽度受限** - 移除页面内容区 `max-w-5xl` 限制,使桌面端页面能够自适应占满可用空间。\n\n### 文档\n\n- **补充渠道预设模型重定向说明** - 在变更记录中补充渠道预设模型重定向的说明,明确 Codex / Chat 客户端实际请求模型与预设映射关系。\n- **调整赞助商展示样式** - 优化 README 中赞助商展示区域的排版与视觉呈现。\n- **添加赞助商注册链接** - 在 README 赞助商信息中补充可直接访问的注册链接。\n- **添加优云智算赞助商信息** - README 新增优云智算赞助商介绍与展示图片。\n\n### 其他\n\n- **version-bump 增强 CHANGELOG 完整性校验** - 版本升级技能现在会校验从上一版本 tag 到 HEAD 的提交记录,补全或移动遗漏的 CHANGELOG 条目。\n- **桌面端 Wails 绑定产物切换为 TypeScript** - 当前工作区包含 `desktop/frontend/bindings/` 下生成绑定从 `.js` 文件删除到 `.ts` 文件新增的变更,需随本轮未提交改动一起审核。\n\n## [v2.8.1] - 2026-05-25\n\n### 修复\n\n- **桌面 App 内嵌 Web UI 删除渠道不生效** (#115) - Wails 3 的 WKWebView 在 iframe 上下文中对原生 `window.confirm()` 不弹对话框、静默返回 `false`,导致删除请求从未发出;替换为 Vuetify `v-dialog` 实现的通用确认框,兼容桌面 iframe 与浏览器双入口\n- **LogViewer autoScroll 开关失效,新日志仍强制滚动** - 移除对 `filteredLogs` 的冗余 watch,改为监听 `searchQuery` 仅在用户改变搜索关键字时强制滚动;新日志增加时改走受 `autoScroll` 控制的 `props.logs.length` watch\n- **缩短引导界面生成的 PROXY_ACCESS_KEY 长度** - 随机熵从 24 字节降为 8 字节,密钥从 `ccx-` + 48 字符 hex(52 字符)改为 `ccx-` + 16 字符 hex(20 字符);64 位熵对本地代理网关已足够安全,显著改善初始引导界面的可读性与可复制性\n\n### 文档\n\n- **修正渠道名称说明,反映同名覆盖的实际行为** - 后端 `createChannel` 在检测到同名渠道时会执行 PUT 覆盖更新,原文案 \"如重复,CCX 会拒绝创建\" 与实际行为不符,容易造成误导\n\n## [v2.8.0] - 2026-05-25\n\n### 修复\n\n- **TestDetectRootDirFallsBackToCwd 在 macOS `/private/var` 符号链接下的误报** - macOS 的 `/var` 是 `/private/var` 的符号链接,`t.TempDir()` 返回前者但 `os.Getwd()` 解析到后者,导致字符串直接比较失败;改用 `filepath.EvalSymlinks` 对齐期望值\n\n### 其他\n\n- **同步 channelpreset 测试期望与当前 preset 实现** - 修复 9 个因测试期望过时导致的失败用例,实现本身正确\n - 移除对 `SupportedModels` 字段的断言:`preset.go` 从未设置该字段(deepseek_chat 与 mimo 共 4 个用例)\n - `glm_chat` / `glm_responses` BaseURL 改为 coding plan 的实际值(`https://open.bigmodel.cn/api/coding/paas/v4#`),匹配 `bestPlanForTarget` 按 plan 数组顺序选第一个非 anthropic plan 的行为\n - `minimax_responses` 的 `CodexToolCompat` / `StripCodexClientTools` 改为 false:`applyTargetDefaults` 显式 override 为 native passthrough 策略,与 DeepSeek 一致\n - `TestBuildPayloadRejectsUnsupportedTarget` 改用 `invalid-target`:kimi preset 已支持 `NativeMessages`,原 kimi+messages 用例失效\n\n## [v2.7.31] - 2026-05-25\n\n### 破坏性变更\n\n- **桌面端 dataDir 路径稳定化,移除 sha1 hash 子目录** - 历史版本将 dataDir 计算为 `{UserConfigDir}/ccx-desktop/{sha1(rootDir)[:10]}`,rootDir 由 `os.Getwd()` 决定,导致从 Dock、Spotlight、终端 `wails3 dev` 等不同方式启动时产生不同 hash 目录(实测一台机器下出现 3 个互不相通的目录),用户的 PROXY_ACCESS_KEY、渠道配置、Agent 配置快照在不同启动方式之间相互不可见\n - 新版本统一使用 `{UserConfigDir}/ccx-desktop/`,与 `bootstrap.log` 同级,dev 与 prod 共用\n - **升级影响**:从旧版本升级后,原 hash 子目录(如 `ccx-desktop/42099b4af0/`)不会自动迁移,用户首次启动会进入空配置状态,需要重新通过引导页设置或手动从旧 hash 目录复制 `.env`、`agent-config-state/`、`.config/` 到新位置\n - 不做自动迁移的原因:无法可靠判断哪个 hash 目录代表\"主用户数据\"\n\n## [v2.7.30] - 2026-05-25\n\n### 新增\n\n- **桌面端首次启动引导流程** - 强制要求用户在启动 CCX 服务前完成最小可用配置,避免直接进入主界面后调用失败\n - 新增 SetupView 全屏引导页:检测 `.env` 中无 `PROXY_ACCESS_KEY` 时显示,预填随机生成的密钥(`ccx-<48hex>`),支持显示/隐藏、复制、重新生成\n - 完成后自动合并写入 `.env`(保留已有键值与注释),自动启动 CCX 服务并跳转至【渠道中心】标签页\n - 引导页展示 `.env` 文件路径方便后续调试,提示后续可在【环境参数】页继续修改其他字段\n - 新增 `IsSetupComplete` / `GenerateProxyAccessKey` 两个 Wails 绑定方法,前者只读判断密钥存在性,后者仅生成预览不写入文件\n - App 启动时三态渲染(Loading 占位 / Setup 引导页 / 主界面),避免界面切换闪烁\n\n- **桌面端 bootstrap 启动日志** - 在 backend manager 初始化前写入固定位置的 `bootstrap.log`,覆盖 dataDir 计算阶段的启动诊断盲区\n - 新增 `mustGetwd` / `mustExecutable` 辅助函数,记录关键启动阶段\n - 便于 Windows 双击无反应、dataDir 解析异常等场景的故障排查\n\n### 修复\n\n- **桌面端版本号显示重复 `vv` 前缀** - 修复桌面端左下角及更新弹窗版本号显示为 `vv2.7.29` 的问题\n - VERSION 文件已自带 `v` 前缀,前端模板不应再硬编码 `v`\n - 修正 `Sidebar.vue` 左下角版本徽标,以及 `UpdateDialog.vue` 标题处的当前/最新版本对比\n\n- **detectRootDir 向上遍历边界条件死循环** - 修复 `filepath.Dir` 到达根目录后返回自身导致的潜在死循环\n - 改为显式检测 `parent == dir` 终止\n - 新增测试验证 fallback 到 cwd 的行为\n\n### 其他\n\n- **NSIS 构建添加 `/INPUTCHARSET UTF8`** - 修复 NSIS 在非 ASCII 路径下编译失败的问题,并将 `ccx-go.exe` 构建产物加入 `.gitignore`\n- **新增 `make install` 命令** - 一键安装根目录、`backend-go/`、`frontend/`、`desktop/`、`desktop/frontend/` 全部依赖\n\n## [v2.7.29] - 2026-05-25\n\n### 修复\n\n- **批量延迟测试在 Chat 标签页报错 `e.forEach is not a function`** - 修复 Chat 渠道批量延迟测试时,前端 `pingAllChatChannels()` 未解包后端 `{\"channels\": [...]}` 响应导致 `forEach` 调用失败的问题\n - `api.ts` 中 `pingAllChatChannels()` 现在正确提取 `resp.channels` 并映射字段,与 Images/Gemini 处理方式一致\n\n- **第三方 provider 直连使用 ANTHROPIC_AUTH_TOKEN** - 修复桌面端第三方 provider(DeepSeek、MiMo、Kimi、GLM、MiniMax、DashScope、OpenCode)直连 Claude Code 时,API Key 错误写入 `ANTHROPIC_API_KEY` 的问题,改为正确写入 `ANTHROPIC_AUTH_TOKEN`\n - `resolveClaudeProvider` 统一将第三方 provider 的 key 归入 `authToken` 返回值,与 CCX provider 行为一致\n - apply、restore、preview 三条路径均通过同一函数解析,无需额外修改\n\n- **配置预览 Diff 掩码后丢失敏感字段变更** - 修复 Agent 配置预览弹窗中,敏感字段(如 `ANTHROPIC_AUTH_TOKEN`)变更时因掩码为 `***` 导致 diff 无法识别变更的问题\n - 新增 `computeJSONDiffWithMask` / `computeTextDiffWithMask`:先在原始内容上做 LCS diff 正确识别变更类型,再用掩码后的内容填充展示行\n - 更新全部 6 个 preview 函数(Claude/Codex apply & restore)使用先 diff 再掩码的方式\n - 新增 `extractNestedStringValues` 支持从嵌套 map(如 `env` 子 map)提取敏感字段值\n - 新增 4 个单元测试覆盖掩码 diff 核心场景\n\n## [v2.7.28] - 2026-05-24\n\n### 修复\n\n- **Codex 第三方 provider 状态识别与恢复逻辑修复** - 修复桌面端 Codex 直连模式下第三方 provider 的运行状态识别和恢复逻辑\n- **Codex Responses 转 Chat 时空 tool_choice 导致上游 400** - 修复当 Codex 请求仅携带非 function 类型 tools(全被过滤)时,`tool_choice` 和 `parallel_tool_calls` 仍被透传给上游 Chat API 导致拒绝的问题\n - `OpenAIChatConverter` 和 `ConvertResponsesToOpenAIChatRequest` 两条路径均增加 tools 存在性检查\n - 无 tools 时自动剥离 `tool_choice` 和 `parallel_tool_calls`,与透传路径 `stripCodexClientOnlyTools` 行为一致\n\n- **WebUI iframe 切换标签时自动刷新** - 修复桌面端切换标签页后 WebUI iframe 白屏的问题,切回 WebUI 标签时自动刷新内容\n\n- **Windows 桌面端双击无反应** - 修复 Windows 生产构建(`-H windowsgui`)下启动失败时错误静默吞掉的问题\n - 添加 Windows MessageBox 弹窗:WebView2 缺失、ccx-go 找不到等致命错误现在会弹窗提示用户\n - 添加顶层 `recover()` 兜底:未处理的 panic 不再导致进程静默退出\n - 添加单实例互斥锁:重复双击时弹窗提示\"已在运行中\",检查系统托盘\n - 添加持久化文件日志:`%APPDATA%\\ccx-desktop\\\\desktop.log` 记录启动日志便于排查\n - NSIS 安装器 `ccx-go.exe` 从可选改为必需,缺失则安装失败\n\n## [v2.7.27] - 2026-05-24\n\n### 新增\n\n- **Codex 直连新增 DashScope / OpenCode Zen / Go 选项** - 桌面端 Codex 直连配置新增 DashScope、OpenCode Zen 和 OpenCode Go 三个新的 provider 选项\n- **DashScope chat/responses 预设扩展** - DashScope 渠道预设增加 GPT 模型重定向和 reasoning mapping 支持\n\n### 修复\n\n- **Codex 直连目标 URL 显示** - 修复 Codex 直连时目标 URL 未正确显示各 provider 的 Responses 端点的问题\n\n## [v2.7.26] - 2026-05-24\n\n### 新增\n\n- **多渠道 Messages 原生透传支持** - Kimi、GLM、MiniMax 新增 Messages 原生透传,DeepSeek codex responses 预设默认开启原生工具透传和 role 规范化\n- **渠道预设扩展** - 新增 OpenCode Zen/Go 和阿里云 DashScope 渠道预设与 Agent 直连支持\n- **Agent 直连选项** - Agent 配置新增 Kimi / GLM / MiniMax 直连选项\n- **渠道中心自动覆盖** - 添加到 CCX 时自动覆盖同名渠道配置\n- **Web UI 刷新按钮** - 内嵌 Web UI 页面添加刷新按钮\n- **Codex OpenAI 切换** - 支持 Codex 无缝切换回 OpenAI provider\n\n### 修复\n\n- **渠道中心文案与 URL** - 修正描述文案错别字,移除 /v1 结尾 base URL 的 # 后缀\n- **渠道中心 target 切换** - 修复切换 target 后被级联重置回 messages 的问题\n- **桌面端二进制路径** - 修复 ccx-go 后端二进制未打包到 App Bundle 的路径错误\n- **vite 插件路径** - 修复 vite 插件路径为绝对路径并更新前端依赖\n- **OpenAI provider 配置** - 修复 OpenAI 模式下目标 URL 显示为空,移除无效配置块写入\n- **监控面板初始化** - 初始化时预探测二进制路径,避免监控面板显示\"未发现\"\n\n### 重构\n\n- **Web UI 刷新按钮位置** - 刷新按钮移至标题栏,WebUITab 通过 defineExpose 暴露刷新方法\n- **渠道预设精简** - 移除渠道预设中所有 SupportedModels 显式赋值,MiniMax 预设精简默认值\n- **ccx-go 查找路径** - 改为向上遍历最多 6 层目录\n\n### 其他\n\n- **Wails 升级** - Wails v3.0.0-alpha.92 升级到 alpha.95\n\n## [v2.7.25] - 2026-05-23\n\n### 修复\n\n- **渠道预设模型映射更新** - 更新桌面端渠道预设的模型映射配置\n- **WebUI 默认语言改为中文** - 前端 WebUI 默认语言从英文改为中文\n- **Agent 代理配置应用按钮文案统一** - 统一桌面端 Agent 代理配置的应用按钮文案\n- **Checksum 文件路径前缀修正** - 去除 CI Release 流程中 checksum 文件的路径前缀\n\n### 其他\n\n- **桌面应用图标资源更新** - 更新桌面应用图标资源文件\n\n## [v2.7.24] - 2026-05-23\n\n### 修复\n\n- **Release workflow Bad credentials 错误** - 修复 CI Release workflow 中 GitHub token 认证失败的问题\n\n## [v2.7.23] - 2026-05-23\n\n### 修复\n\n- **MiMo 不同计费模式支持独立 API Key** - ProviderKeyAsset 的 Assets map 改为 `provider:planID` 复合 key,同一 provider 不同 plan 不再互相覆盖;前端查找保存的 key 时优先匹配 plan-specific key,回退到 provider-only key 兼容旧数据\n\n## [v2.7.22] - 2026-05-23\n\n### 修复\n\n- **桌面端 CCX Provider 应用按钮状态修复** - CCX provider 的应用按钮不再依赖网关在线状态,避免网关未启动时无法操作\n- **Release checksum 路径修复** - 修复 Release checksum 生成路径错误导致三个主平台构建失败的问题\n\n## [v2.7.21] - 2026-05-23\n\n### 修复\n\n- **Release checksum 路径前缀修正** - 修复 CI release 流程中 checksum 文件路径前缀错误并精简 finalize job\n- **桌面构建 ccx-go 查找路径** - 修正桌面端构建时 ccx-go 后端二进制的查找路径\n- **渠道中心 Base URL 过滤** - 桌面端渠道中心按 target 协议过滤 Base URL 选项,避免显示不匹配的选项\n\n### 其他\n\n- **恢复 Wails 3 TypeScript 绑定文件** - 恢复桌面端 Wails 3 自动生成的 TypeScript 绑定文件\n- **version-bump 技能文档更新** - 更新 version-bump 技能的 GitHub Actions 集成说明,新增 `gh run list` 查看构建进度命令\n\n## [v2.7.20] - 2026-05-23\n\n### 修复\n\n- **MiMo 渠道模板 Base URL 修正** - 修正 MiMo 渠道模板 Base URL 为 Anthropic 协议入口,同时调整计费模式表单顺序\n- **网关监控默认显示配置端口** - 网关监控打开时默认显示 .env 中配置的端口,启动后端时优先使用用户配置的端口\n- **Windows Desktop amd64 安装包缺失** - 修复 CI 中 Windows Desktop amd64 安装包在 Release 中缺失的问题\n- **macOS 应用图标重新设计** - 重新设计应用图标以适配 macOS Human Interface Guidelines\n- **macOS 托盘图标交互修复** - 修复 macOS 托盘图标点击后窗口置顶和位置偏移的问题\n\n## [v2.7.19] - 2026-05-23\n\n### 修复\n\n- **日志文件混入精简格式重复内容** - 修复 logger 将 stdout 精简格式与原始完整 JSON 双重写入日志文件的问题,确保日志文件仅保留原始完整记录\n\n## [v2.7.18] - 2026-05-23\n\n### 修复\n\n- **DMG 安装体验与图标显示** - 改善 macOS DMG 安装包的安装体验与应用图标显示效果\n\n## [v2.7.17] - 2026-05-23\n\n### 修复\n\n- **Task 依赖链 ARCH 传递遗漏** - 修复 CI 中 Task 依赖链未正确传递 ARCH 环境变量的问题\n\n## [v2.7.16] - 2026-05-23\n\n### 修复\n\n- **桌面构建 ARCH 变量未通过 Task 依赖链传递** - 修复 CI 中桌面端构建时 ARCH 环境变量未正确传递到下游 Task 步骤的问题\n\n### 其他\n\n- **版本升级技能新增本地编译验证** - version-bump 技能在创建 tag 前强制执行本地测试和构建验证,编译失败时中止发布流程\n\n## [v2.7.15] - 2026-05-23\n\n### 变更\n\n- **移除后端 OTA 更新机制** - 删除后端 updater 包及相关 API 接口,桌面端更新改为跳转到 GitHub Releases 下载页面\n\n## [v2.7.14] - 2026-05-23\n\n### 新增\n\n- **桌面端 .env 密码字段复制按钮** - .env 配置中密码类型字段(如 `PROXY_ACCESS_KEY`)添加复制按钮,方便用户快速复制密钥值\n\n### 修复\n\n- **密码字段留空时隐藏复制按钮** - 当密码类型字段值为空时自动隐藏复制按钮,避免无效操作\n\n### 变更\n\n- **移除后端 OTA 自动更新** - 删除 `updater` 包、`/api/update/check` 接口及相关配置,桌面端更新改为跳转到 GitHub Releases 下载页面\n\n### 其他\n\n- **Release workflow 桌面构建前置修复** - CI 桌面端构建步骤前确保 `bin` 目录存在,修复构建流程中目录缺失问题\n\n## [v2.7.13] - 2026-05-23\n\n### 新增\n- **Linux ARM64 桌面端支持**:CI 新增 `ubuntu-24.04-arm` runner 构建 arm64 AppImage,updater 移除 arm64 限制。\n- **前端测试框架**:引入 vitest + jsdom,新增 `env-file.ts` 20 个单元测试。\n- **Bindings 同步 CI 守护**:新增 `check-bindings.yml` workflow,PR 修改 Go 接口时自动验证 TypeScript bindings 同步。\n- **桌面端 .env 文件外部编辑器打开**:Env 配置页新增\"用编辑器打开\"按钮,自动检测系统已安装的编辑器(VS Code、Cursor、Sublime Text、Vim 等),支持 `$EDITOR` / `$VISUAL` 环境变量优先。多个编辑器时提供下拉选择。\n\n- **Desktop 内置 ccx-go 后端**:macOS .app、Windows NSIS、Linux AppImage/deb/rpm 安装包现在内置 ccx-go 后端二进制,用户无需单独下载。Desktop 与 ccx-go 共用版本号。\n- **Release Sigstore 签名**:\n - 在 CI 发布流程中集成 Sigstore / cosign keyless signing,使用 GitHub Actions OIDC 颁发短期证书,无需管理密钥。\n - 三个构建平台(macOS / Windows / Linux)各自生成平台级 checksums 并签名,`finalize` job 合并为全平台 `checksums.txt` 后再次签名。\n - 新增 `docs/guide/verification.md` 与 `docs/en/guide/verification.md` 验证文档,说明 cosign 安装与签名验证步骤。\n - 现有 `.sha256` sidecar 文件与 updater 行为不变。\n\n### 变更\n\n- **桌面端 Env 配置访问控制置顶**:`PROXY_ACCESS_KEY` 和 `ADMIN_ACCESS_KEY` 配置组在 Env 配置表单中移到首位,便于首次配置时优先设置。\n- **补齐桌面端测试覆盖**:新增 `configservice`(19 个纯函数 + 集成测试)和 `backend/manager`(16 个测试)测试文件。\n\n### 修复\n- **MSIX 打包缺少 wails.json**:新建 `desktop/wails.json`,修复 `wails3 tool msix --config` 引用不存在的文件。\n- **parseEnvContent 兼容性**:Go 端 `.env` 解析支持 `export` 前缀和 `\\r\\n` 换行符。\n- **桌面端 Agent 配置服务初始化错误日志**:`configservice.New()` 失败时不再静默丢弃错误,改为输出 `[Desktop-Init]` 日志便于排查。\n- **渠道对话框下拉菜单位置偏移**:修复 Vuetify v-select/v-combobox 在添加/编辑渠道对话框内首次打开时菜单位置计算错误的问题,通过 eager 预渲染与 resize 触发确保下拉菜单正确定位。\n- **DeepSeek `user_id` 限速与隔离字段透传**:\n - 让内部会话标识提取支持 Chat 请求体顶层 `user_id`,用于 Trace 亲和性与调度追踪。\n - 补齐 Chat → Claude、Messages → OpenAI/Responses、Responses → Claude 跨协议转换中的 `user_id` / `metadata.user_id` 映射,避免用户隔离标识在协议转换时丢失。\n - 增加相关单元测试覆盖关键转换路径。\n- **Responses 转 Claude 上游 reasoning 回传补齐**:\n - 在 Responses 转 Claude/Messages 风格请求且启用 `PassbackReasoningContent` 时,同步补齐 `reasoning_content`,避免 MiMo 等上游跨渠道历史请求缺少 reasoning 回传字段。\n\n## [v2.7.12] - 2026-05-22\n\n### 修复\n\n- **Windows Release 构建 NSIS makensis 不在 PATH** - 修复 CI 中 Windows Release 构建时 NSIS `makensis` 工具不在 PATH 导致构建失败的问题\n\n## [v2.7.11] - 2026-05-22\n\n### 新增\n\n- **渠道预设 Token Plan 下拉显示实际 Base URL** - 桌面端渠道预设 Token Plan 下拉选项现在会显示实际的 Base URL,便于用户识别和选择\n\n## [v2.7.10] - 2026-05-21\n\n### 修复\n\n- **MiMo Messages 渠道流式响应只输出 thinking 的问题**:\n - 停止为缺少真实思考内容的历史 assistant 消息注入 `\"(no prior reasoning recorded)\"` 占位 `thinking` 或 `reasoning_content`,避免 MiMo 将正式回答续写进假 thinking block,或继续因假回传返回 `reasoning_content ... must be passed back` 400。\n - 为历史 assistant 消息补齐 MiMo 要求的顶层 `reasoning_content` 字段:保留真实 `thinking` 块原文并同步回传到顶层字段,仅移除历史请求中旧版本注入的占位/空 `thinking` 块;若 assistant 历史没有真实思考内容,则补空字符串 `reasoning_content: \"\"`(保持顶层字段存在);若历史 assistant 仅剩占位 thinking,则转为中性非空 text,避免空 content 或轮次丢失触发上游校验问题。\n - `thinking -> reasoning_content` 转换改为“保真搬运”:不再对真实思考文本做 trim/拼接改写;当消息已含顶层 `reasoning_content` 时保持原值,避免内容被改写后触发上游回传一致性校验失败。\n - 修复请求预处理误删真实 thinking 的问题:`thinking` 块里 `signature` 为空/null 时仅移除 `signature` 字段,不再删除整块 `thinking`,避免后续 `reasoning_content` 回传被掏空。\n - 增强 failover 非重试判定:当上游 400 的 `error.param`/`error.message` 命中 `reasoning_content in the thinking mode must be passed back` 时,判定为 schema 参数错误,不再继续 Key/渠道级 failover,避免请求漂移到其他渠道与错误熔断。\n - 流式预检测改为只有正式 text 或工具语义内容才视为有效响应;thinking-only 流会被判定为空响应并触发重试/失败,避免静默返回 200。\n\n## [v2.7.9] - 2026-05-20\n\n### 修复\n\n- **Gemini 渠道流式响应下 tool call ID 生成策略优化**:\n - 在 `backend-go/internal/providers/gemini.go` 中,将 Gemini 流式响应下 functionCall 的 tool ID 生成策略从不稳定的索引拼接形式(如 `toolu_%d`)优化为稳定的、带有 `call_` 前缀的 16 字节随机 hex 字符串(通过 `crypto/rand` 生成),使 ID 在跨多轮对话时更具唯一性且对齐主流(如 OpenAI `call_xxx` 风格),避免在复杂 agent 多轮调用中发生冲突。\n - 在 `backend-go/internal/providers/gemini_stream_test.go` 中添加对应的测试断言,验证 tool call ID 的 `call_` 前缀生成逻辑。\n- **修复 Gemini 流式响应中 functionCall 场景的 `stop_reason` 映射**:\n - 在 `backend-go/internal/providers/gemini.go` 中,引入 `hasToolUse` 状态标志。当流式响应中检测到 functionCall 时,将 `message_delta` 的 `stop_reason` 正确映射为 `tool_use`(而不是默认的 `end_turn`),确保下游消费端能够正确感知并启动工具调用执行,完全符合 Claude 规范。\n - 在 `backend-go/internal/providers/gemini_stream_test.go` 中新增 `TestGeminiHandleStreamResponse_FunctionCallMapsStopReasonToToolUse` 单元测试,模拟 functionCall 流式响应并验证 `stop_reason` 映射。\n\n## [v2.7.8] - 2026-05-20\n\n### 重构\n\n- **清理 `providers` 包历史 lint 告警**:\n - `backend-go/internal/providers/openai.go` 中 `ConvertToClaudeResponse` 转换 tool_call 入参时,给 `json.Unmarshal` 补上错误检查;解析失败时降级保留 `toolCall.Function.Arguments` 原始字符串,避免静默丢失参数(errcheck)。\n - `backend-go/internal/providers/openai_stream_usage_test.go` 中 `TestOpenAIProvider_ConvertToClaudeResponse_CacheFieldInJSON` 给 `json.Unmarshal` 补上 `t.Fatalf` 错误检查,防止 unmarshal 失败时后续断言基于空 map 假阳性通过(errcheck)。\n - `backend-go/internal/providers/responses.go` 删除未被任何地方调用的 `formatFunctionCallHistory` 函数(13 行):与配套 `formatFunctionCallOutputHistory` 不对称——`function_call` 分支始终直接保留原始 item,不需要降级为文本消息,该函数是早期设计残留(unused)。\n - `backend-go/internal/providers/claude.go` 中 `ConvertToProviderRequest` 模型重定向条件 `upstream.ModelMapping != nil && len(upstream.ModelMapping) > 0` 简化为 `len(upstream.ModelMapping) > 0`(Go 对 nil map 的 `len()` 定义为 0,gosimple/S1009)。\n\n## [v2.7.7] - 2026-05-20\n\n### 修复\n\n- **`messages` 走 Gemini 上游时 tool_result 函数名错位导致工具调用沉默丢失**:\n - 修复 `backend-go/internal/providers/gemini.go` 的 `convertMessage` 在转换 Claude `tool_result` 为 Gemini `functionResponse` 时,把 `name` 字段直接填成 `tool_use_id` 的问题。Gemini 协议要求 `functionResponse.name` 必须等于前面对应 `functionCall.name`(函数名),否则上游无法匹配到对应的工具调用,会沉默返回空内容(典型表现:MCP / 多轮工具历史在 Gemini 上游被静默丢弃,下游模型回合显示空白)。\n - 新增 `buildToolUseIDNameMap`,在 `convertMessages` 入口先扫一遍 Claude 历史,构建 `tool_use_id → name` 映射;`convertMessage` 接收该映射并在 tool_result 转换时回查正确函数名。查不到映射的孤立 tool_result 回退使用 `tool_use_id` 兜底,避免完全丢字段。\n - 在 `backend-go/internal/providers/gemini_tool_result_test.go` 中将历史 fixture 升级为含 tool_use 的完整对话,并新增针对 id→name 映射回查的断言用例。\n\n### 新增\n\n- **Chat 渠道透传思考回传支持**:\n - 在 `backend-go/internal/handlers/chat/handler.go` 中引入了与 Messages 渠道一致的预处理逻辑,自动清理空 signature 字段和历史畸形 thinking 内容块,预防上游参数校验 400 错误。\n - 在 `buildProviderRequest` 中,当 `PassbackReasoningContent` 关闭时,在发送给 Claude 协议上游前自动剥离历史 thinking 块,避免跨上游复用签名导致 signature 错误。\n - 在 `backend-go/internal/config/config_chat.go`、`channels.go` 和 `channel_metrics_handler.go` 中同步支持了 `PassbackReasoningContent` 字段的更新与返回。\n - 修改了 `frontend/src/components/AddChannelModal.vue`,使得“回传 Reasoning Content”开关在 Chat 渠道且服务类型为 claude 时也能正常显示和配置。\n - 在 `deepseek_thinking_matrix_test.go` 中新增了 `TestChatHandler_PassbackReasoningContent` 测试用例。\n\n### 修复\n\n- **`messages` 走 Gemini 上游时 MCP / 自定义工具历史丢失导致重复调用**:\n - 修复 `backend-go/internal/converters/responses_to_gemini.go` 中 `responsesItemToGeminiContents` 漏掉 `custom_tool_call` 与 `custom_tool_call_output` 两类 item 的问题。归一化阶段会把 MCP 工具(如 `mcp__serena__*`)落到这两种类型,原实现走 switch 的隐式 default 直接丢弃,导致历史里只剩模型空回合,Gemini 视为\"工具调用未返回\"并不停重复发起同一工具调用。\n - 现在两类 item 分别转为 `model` 角色的 `FunctionCall`(携带 `DummyThoughtSignature`,与 `function_call` 行为一致)和 `user` 角色的 `FunctionResponse`,并对缺 `call_id`/`name` 的孤立项做兜底丢弃,避免构造出 Name 为空的 part 触发上游 400。\n - 在 `backend-go/internal/converters/gemini_responses_roundtrip_test.go` 新增 `TestResponsesToGeminiRequest_PreservesCustomToolCallHistory` 和 `TestResponsesToGeminiRequest_CustomToolCallOutputWithoutCallIDDropped` 两条回归用例。\n- **SUBSCRIPTION_NOT_FOUND 余额不足故障转移**:\n - 将 `SUBSCRIPTION_NOT_FOUND` 错误码和 `\"no active subscription found\"` 错误信息识别为余额不足(`insufficient_balance`),从而正确触发渠道黑名单和故障转移。\n - 在 `failover_test.go` 和 `stream_test.go` 中补充了对应的普通请求与流式请求测试用例。\n- **Gemini 渠道 tool_result 数组解析报错**:\n - 修复了 `messages` 接口转上游 `gemini` 类型渠道时,`tool_result` 包含数组(Content Blocks)导致的反序列化报错问题。\n - 将 `GeminiFunctionResponse.Response` 字段类型从 `map[string]interface{}` 变更为 `interface{}`,提高结构体容错性。\n - 在 `backend-go/internal/providers/gemini.go` 的 `convertMessage` 方法中,对 `tool_result` 的 `content` 进行了智能解析和规范化,确保转换后的 `response` 始终是一个符合 Gemini 官方协议要求的 JSON 对象。\n - 新增了 `gemini_tool_result_test.go` 单元测试,覆盖了 `tool_result` 为数组、字符串、JSON 对象等各种场景,验证其能正确转换为 Gemini 期望的格式。\n- **Gemini 渠道空 text part 触发上游 400**:\n - 修复了 `messages` 接口转上游 `gemini` 类型渠道时,Claude assistant 消息中常出现的空 text 块(如带 tool_use 时的前置 padding)被无脑翻译为 `{\"text\": \"\"}`,被严格按 Gemini protobuf 校验的上游(如 vip.undyingapi.com)判定为 `contents[X].parts[Y].data: required oneof field 'data' must have one initialized field` 返回 400 的问题。\n - 在 `backend-go/internal/providers/gemini.go` 的 `convertMessage` 中处理 `text` 类型 content 时跳过空字符串,确保不向 Gemini 上游下发无意义的空 Part。\n - 在 `gemini_tool_result_test.go` 中新增 `TestGeminiProvider_ConvertMessage_SkipsEmptyTextBlock` 与 `TestGeminiProvider_ConvertMessage_KeepsNonEmptyTextBlock` 两条用例,覆盖空 text 块被剔除与非空 text 块仍保留两种场景。\n- **Claude→Gemini provider 转换遵循 thought_signature 渠道开关**:\n - 修复了 `messages` 接口转上游 `gemini` 类型渠道时,Claude 协议本身不携带 thought_signature 字段,导致严格校验的上游(如 vip.undyingapi.com)返回 `Function call is missing a thought_signature in functionCall parts` 400 的问题。\n - 在 `backend-go/internal/providers/gemini.go` 的 `convertToGeminiRequest` 中按上游 `injectDummyThoughtSignature` / `stripThoughtSignature` 开关注入 part 层级的 `thoughtSignature`,与原生 Gemini 入口(`handlers/gemini/handler.go`)的策略对齐:默认不修改、`injectDummyThoughtSignature` 开启时注入 `DummyThoughtSignature`、`stripThoughtSignature` 优先级更高且在该场景为 no-op(Claude 协议本就无签名)。\n - 在 `gemini_tool_result_test.go` 中新增 `TestGeminiProvider_ConvertToGeminiRequest_InjectDummyThoughtSignature`、`TestGeminiProvider_ConvertToGeminiRequest_DefaultNoSignature`、`TestGeminiProvider_ConvertToGeminiRequest_StripThoughtSignatureNoOp` 三条用例。\n- **补齐多渠道 thought_signature 字段链路**:\n - `backend-go/internal/config/config_messages.go` 的 `UpdateUpstream` 补充接收 `StripThoughtSignature`(之前漏了,开启了等于无效)。\n - `backend-go/internal/config/config_chat.go`、`config_responses.go` 的 update 函数补充接收 `InjectDummyThoughtSignature` 和 `StripThoughtSignature`,对齐 [[feedback_channel_config_field]] 的\"五类渠道更新函数必须同步应用新字段\"规则。\n - `backend-go/internal/handlers/messages/channels.go`、`chat/channels.go`、`responses/channels.go` 的 `GetUpstreams` 把这两个字段透出给前端;`handlers/channel_metrics_handler.go` 的 dashboard 端点同步补充。\n - `frontend/src/components/AddChannelModal.vue` 把 `injectDummyThoughtSignature` 开关的显示条件由仅 `props.channelType === 'gemini'` 改为 `(gemini || messages) && form.serviceType === 'gemini'`;`stripThoughtSignature` 开关的显示条件由仅 gemini 改为 `form.serviceType === 'gemini'` 且 channelType 属于 gemini/messages/chat/responses。chat/responses 渠道当前在 handler/converter 层默认无条件注入 dummy 签名,因此前端不暴露 `injectDummy` 开关以避免误导。\n- **`maskKey` 短密钥脱敏过度**:\n - 修复了 `backend-go/main.go` 中启动日志 `maskKey` 对长度 ≤ 4 的密钥直接输出 `****`、完全遮蔽用户能识别的字符的问题。\n - 现在对长度 ≤ 3 的密钥保留首字符(如 `abc` → `a****`),长度 4~8 保留首尾字符,长度 > 8 保留前后各 2 字符。\n\n### 新增\n\n- **CCX 桌面外壳 MVP** - 新增 Wails3 桌面外壳,用于将现有 CCX 后端作为核心服务构件进行启动、停止、重启、托盘驻留和状态监控;外壳通过现有 `/health` 探活并在内置标签页中加载 CCX Web UI,避免改动核心代理、调度和现有 Web 管理界面逻辑。\n - 新增 `desktop/` Wails3 项目,包含后端子进程 supervisor、托盘菜单、状态页、内嵌 Web UI 标签页和前端绑定。\n - 根 `Makefile` 新增 `desktop-dev` / `desktop-build`,复用现有前端 embed 与 Go 后端构建流程。\n - 调整 `.gitignore`,保留 Wails `desktop/build/` 源配置,同时继续忽略桌面构建产物。\n\n### 修复\n\n- **桌面外壳 Wails Runtime 422** - 升级 `wails3` CLI 与 `@wailsio/runtime` 客户端到 alpha.92 / alpha.79,匹配 alpha.79 引入的 binding transport refactor(请求改用 JSON body),消除旧 CLI(alpha.40)读 URL query 时的 `missing object value` 422 错误,桌面事件订阅(`desktop:show-tab` / `desktop:tray-error`)和 typed binding 调用恢复正常;同步升级前端工具链 vite 8 / vue-tsc 3 / typescript 6 / vue 3.5。\n- **空/畸形 Tool Call 自动重试** - 在 Fuzzy 模式下将空参数或非法 JSON 的 tool/function call 视为空响应并复用现有 failover,降低上游偶发 `Read({})` 等畸形工具调用对下游客户端的影响\n - 影响模块:Messages/Chat/Responses/Gemini 的非流式空响应判定、Messages/Responses 流式预检、Chat 流式写头前缓冲预检\n\n## [v2.7.4] - 2026-05-18\n\n### 修复\n\n- **Mimo thinking mode 兼容性** - 修复 mimo(小米 MiMo)等 reasoning model 上游在多轮对话切换场景下返回 400 \"The reasoning_content in the thinking mode must be passed back to the API\" 的问题\n - 之前实现将 thinking 块转为顶层 `reasoning_content` 字段(OpenAI 风格),实测 mimo 的 Anthropic 协议下不认顶层 `reasoning_content`,仍返回 400\n - 新实现保留所有原有 thinking 块,对缺少 thinking 块的 assistant 消息注入占位 thinking 块 `{\"type\":\"thinking\",\"thinking\":\"(no prior reasoning recorded)\"}`,让 mimo 通过 thinking mode 校验\n - 仅在渠道开启 `passbackReasoningContent: true` 时生效,对其他渠道无影响\n - 注意:旧对话切到 mimo 时模型上下文缺少真实推理内容,可能出现幻觉/指令遵循下降(mimo 官方公告明示的代价)\n- **OpenAI Provider 缓存 Token 丢失** - 修复 OpenAI Provider 在协议转换(OpenAI/DeepSeek → Claude Messages)时丢弃上游 cache usage 字段的问题(#76)\n - 流式:`HandleStreamResponse` 现在从 terminal usage chunk(`choices: []`)中提取 `prompt_cache_hit_tokens`、`prompt_tokens_details.cached_tokens` 等字段,并注入到 final `message_delta.usage` 中\n - 非流式:`ConvertToClaudeResponse` 新增二次 raw parse,将 DeepSeek/OpenAI 格式的 cache 字段映射到 `CacheReadInputTokens`\n - Metrics:`annotatePromptTokensTotalForProvider` 扩展到 OpenAI Provider,确保缓存命中归一化口径与 Responses Provider 一致\n\n## [v2.7.3] - 2026-05-18\n\n### 优化\n\n- **Docker 构建优化** - 使用 Go 交叉编译替代 QEMU 模拟,优化多架构镜像构建流程并增加层缓存,显著缩短 CI 构建时间\n\n### 修复\n\n- **Mimo reasoning_content 回传修复** - 修复 `convertThinkingToReasoningContent` 在将 thinking 块提取为 `reasoning_content` 字段时未从 content 数组中移除 thinking 块的问题;当从 Claude 原生渠道切换到 mimo 渠道时,残留的 thinking 块导致 mimo 上游返回 400 \"The reasoning_content in the thinking mode must be passed back to the API\" 错误\n\n## [v2.7.2] - 2026-05-17\n\n### 新增\n\n- **缓存读写总统计** - 全局统计图表和模型统计图表新增缓存 Token 读写统计展示\n - 后端:`ModelHistoryDataPoint` 新增 `cacheCreationTokens`/`cacheReadTokens` 字段;`GetModelStatsHistory` 和全局统计的模型分桶聚合逻辑补充缓存 Token 累加\n - 前端 `GlobalStatsChart`:Summary cards 和 compact summary 新增缓存 R/W 统计(有数据时显示);Tokens 图表视图动态添加 Cache Read/Write 系列线\n - 前端 `ModelStatsChart`:新增 Cache 视图切换,展示按模型分组的缓存 Token 趋势\n - 前端 `api.ts`:`ModelHistoryDataPoint` 类型补充缓存字段\n\n## [v2.7.1] - 2026-05-17\n\n### 新增\n\n- **渠道统计对齐接入点统计** - 渠道 Key 趋势图新增 7d/30d 时间维度,并展示总请求次数、成功率、输入/输出 Token 汇总卡片,与接入点总览统计对齐 (#72)\n - 后端:`HistoryDataPoint` 补充 Token 字段;`MetricsHistoryResponse` / `ChannelKeyMetricsHistoryResponse` 新增 `summary` 汇总;Key 趋势接口移除 24h 上限,支持 30 天范围 SQLite 聚合\n - 前端:`KeyTrendChart` 新增 7d/30d 按钮、summary cards、长时间范围 x 轴日期格式\n - SQLite:新增 `idx_records_api_type_metrics_key_timestamp` 复合索引优化渠道级长范围查询\n\n### 改进\n\n- **Override 熔断自动清除** - 当驾驶舱设置的 override(next channel)序列中所有渠道均不可用(熔断)时,调度器自动清除该 override 而非仅跳过,避免前端 NEXT 标签长期残留\n- **渠道熔断状态下发前端** - `GetConversationChannelsByKind` API 返回的渠道信息新增 `circuitOpen` 字段,前端驾驶舱可实时显示渠道熔断状态(FUSED 标记)\n\n### 修复\n\n- **Claude 协议空 Text Block 兼容开关** - 修复严格校验的第三方 Claude 协议上游因 Claude Code 在 `tool_use` 前插入裸空 `{\"type\":\"text\",\"text\":\"\"}` 占位块而返回 400 的兼容性问题;新增 Messages 渠道 `stripEmptyTextBlocks` 开关,在转发前按需移除空 text block,并同步接通前端配置、渠道视图与回归测试。\n- **Responses SSE keep-alive** - 为 Responses 流式代理增加 SSE keep-alive 机制,每 15 秒向下游发送 `: keepalive` 注释行,防止 DeepSeek 等慢上游思考期间触发 Codex 客户端 idle timeout 断连 (#67)\n\n## [v2.7.0] - 2026-05-17\n\n### 新增\n\n- **Responses Compact 本地压缩** - 当 responses 渠道上游为非原生 Responses 类型(openai/claude/gemini)时,`/v1/responses/compact` 端点自动切换为本地 compact 模式:将对话历史格式化为 transcript,通过现有 converter 管线发送普通请求让模型生成摘要,再包装为 Responses 格式返回。支持流式/非流式跟随客户端、session 历史读取与 compact 结果写回、大输入截断保护。原生 responses 上游若返回 404/405/501 也会自动回退本地 compact\n- **SessionManager 新增只读查询与压缩会话创建** - 新增 `GetSessionByResponseID` 通过 responseID 只读查找 session;新增 `CreateCompactedSession` 创建压缩后的轻量会话并记录映射\n- **ResponsesProvider 提取公共请求构建方法** - 新增 `ConvertBodyToProviderRequest` 公共入口,接受 bodyBytes 参数复用现有 URL/转换/认证逻辑,供 compact 等场景调用\n\n### 改进\n\n- **Messages 渠道模型列表兼容国内 Claude 协议入口** - 当 `base_url` 以 `/anthropic`、`/claude`、`/messages` 结尾时(如 `https://api.deepseek.com/anthropic`),模型列表获取自动尝试三段候选 URL:当前路径 → 剔除协议尾段 → 纯域名根路径,解决国内服务商模型接口不在兼容协议子路径下的问题。管理端\"获取模型\"同步适配。\n\n---\n\n## 历史版本(v2.0.0-go ~ v2.6.99)\n\n> 以下为早期版本的里程碑摘要,`v2.7.0` 及之后保留完整条目。更细粒度的历史变更可通过 `git log` 查看。\n\n### v2.6.x(2026-02 ~ 2026-05)\n\n- **渠道能力测试** - 完成 4 协议互转、Codex 自定义工具兼容、RPM/超时/取消/重定向验证、结果缓存与多协议并发轮询。\n- **独立 Images 渠道** - 新增 `/v1/images/generations`、`/v1/images/edits`、`/v1/images/variations`,接入独立 failover、metrics、日志和前端管理。\n- **熔断器三态状态机** - 引入 `closed` / `open` / `half_open`、指数退避、单探针恢复、持久化和 Dashboard 状态展示。\n- **渠道日志生命周期** - 支持 `pending`、`connecting`、`first_byte`、`streaming`、`completed`、`failed`、`cancelled` 全链路追踪与前端实时刷新。\n- **会话调度看板** - 新增 Conversation Dashboard、ConversationTracker、OverrideManager 与按对话覆盖渠道序列能力。\n- **Responses Compact 本地压缩** - 非原生 Responses 上游自动通过 converter 管线执行本地 compact,并写回轻量会话。\n- **Vision 能力路由** - 新增 `noVision`、`noVisionModels`、`visionFallbackModel`,按图片输入动态跳过或降级模型。\n- **Thinking / Reasoning 兼容** - 支持 DeepSeek、MiMo、Gemini 等上游的 `thinking`、`reasoning_content`、`thought_signature` 双向转换和回传。\n- **Codex 工具兼容层** - 支持 Codex `apply_patch` 代理工具、字符串工具、native tool passthrough、工具调用回写和 `codexToolCompat` 统一开关。\n- **模型与路由能力增强** - 支持模型过滤包含/排除规则、`X-Channel` 指定目标渠道、渠道级代理、自定义请求头、多 BaseURL 等价去重。\n- **缓存与指标口径统一** - 归一 Responses / Messages 缓存命中率、Token usage、Key / Model 维度统计和历史分桶。\n- **自动恢复机制** - 支持 UTC 0/8/16 时段自动恢复黑名单 key、half-open 探测和 suspended 渠道最小激活。\n- **前端体验与性能优化** - 完成多语言管理界面、移动端导航、能力测试弹窗重构、SVG/Tooltip 常驻资源收敛和内存优化。\n- **关键修复** - 覆盖 Fuzzy 模式 5xx 误判、空响应 failover、metadata.user_id JSON 对象兼容、401 字符串认证错误拉黑、客户端取消误计失败等问题。\n\n### v2.5.x(2026-01)\n\n- **Failover 逻辑模块化** - 提取通用 failover 分类、重试和黑名单判定逻辑。\n- **Gemini 兼容增强** - `thought_signature` 可配置,补齐 Gemini CLI tools schema 与 Dashboard 字段。\n- **指标记录架构优化** - 修复二次计数、客户端取消计数、换 key 后历史累计、渠道删除后串桶等问题。\n- **渠道管理增强** - 支持渠道置顶/置底、前端模型映射智能选择、隐式缓存读取推断。\n- **关键修复** - 包括 Gemini functionDeclaration parameters 类型、Models API 日志标签、响应 model 字段改写可配置化。\n\n### v2.4.x(2025-12 ~ 2026-01)\n\n- **Gemini API 完整实现** - 新增 Gemini API 模块、路由、历史指标和前端管理界面。\n- **统计图表增强** - 新增全局流量 / Token 图、Key 趋势图、缓存命中率统计和多模型趋势展示。\n- **低质量渠道处理机制** - 支持 `lowQuality` 渠道标记、暂停清理促销期和输出验证日志。\n- **配置模块化拆分** - 按渠道类型拆分 config 包,减少单文件配置膨胀。\n- **多端点调度优化** - 支持多 BaseURL failover、预热排序、非阻塞动态排序和并发竞争修复。\n- **关键修复** - 覆盖流式工具调用分裂、空 signature 导致 Claude API 400、内容审核无限重试、403 预扣费降级、SSE 事件完整性等问题。\n\n### v2.3.x(2025-12)\n\n- **渠道级 API Key 策略与多 BaseURL** - 支持多 key / 多 URL 管理、促销期状态展示和延迟测试优化。\n- **SQLite 指标持久化** - 引入本地指标存储,补齐 Responses API Token usage 与 Messages 缓存 TTL 细分统计。\n- **快速添加渠道** - 支持协议自动检测、引号 / 等号 / 空格分割、`settings.json` 解析和多 Base URL 输入。\n- **Models API 端点** - 新增模型聚合查询能力并完善 API Key / Base URL 去重。\n- **关键修复** - 包括滑动窗口重建、Responses usage 缺失、缓存 token 检测、total_tokens 零值补全。\n\n### v2.2.x ~ v2.1.x(2025-12)\n\n- **Handlers 模块重构** - 将 handlers 重构为同级子包结构,完善 Stream 错误处理与 CountTokens 安全加固。\n- **指标系统重构** - 指标绑定到 Key 级别,修复熔断器、单渠道路径指标和非 failover 错误计数。\n- **错误处理与历史数据** - 新增 Fuzzy Mode 错误处理开关、渠道指标历史数据 API、Key 级使用趋势图和失败率可视化。\n- **协议兼容增强** - 支持 TransformerMetadata、CacheControl、BaseURL `#` 结尾语义和 FinishReason 统一映射。\n- **稳定性修复** - 覆盖请求体大小限制、goroutine 泄漏、数据竞争、优雅关闭、流式日志合成器类型等问题。\n\n### v2.0.x(2025-10 ~ 2025-12)\n\n- **Go 版本初始发布** - 完成核心功能移植、单文件部署和性能提升。\n- **核心组件落地** - 引入 ChannelScheduler、MetricsManager、TraceAffinityManager 等基础模块。\n"}}, "shared/channel-presets/codex-responses.json": {"ebe33699466c4c93f605d8b16e5eb0567c127bd5": {"t": 1785674315.578002, "n": 4792, "h": "24b13f2fb887df84", "c": "{\n \"schemaVersion\": 1,\n \"providers\": {\n \"deepseek\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"deepseek-v4-pro\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"gpt\": \"max\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false,\n \"noVision\": true\n },\n \"mimo\": {\n \"modelMapping\": {\n \"codex\": \"mimo-v2.5-pro\",\n \"gpt\": \"mimo-v2.5-pro\",\n \"mini\": \"mimo-v2.5-pro\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"high\",\n \"mimo-v2.5\": \"high\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"serviceType\": \"responses\",\n \"codexToolCompat\": true,\n \"stripCodexClientTools\": true,\n \"noVisionModels\": [\"mimo-v2.5-pro\"],\n \"visionFallbackModel\": \"mimo-v2.5\",\n \"rateLimitRpm\": 80\n },\n \"compshare\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"MiniMax-M2.7\": \"xhigh\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false,\n \"noVisionModels\": [\"glm-5.2\", \"deepseek-v4-flash\"],\n \"visionFallbackModel\": \"MiniMax-M2.7\"\n },\n \"runapi\": {\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"unity2\": {\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"minimax\": {\n \"modelMapping\": {\n \"codex\": \"MiniMax-M2.7\",\n \"gpt\": \"MiniMax-M3\",\n \"mini\": \"MiniMax-M2.7\"\n },\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"dashscope\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"deepseek-v4-pro\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"gpt\": \"max\"\n }\n },\n \"opencode-zen\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"noVisionModels\": [\"glm-5.2\", \"deepseek-v4-flash\"],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"opencode-go\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"noVisionModels\": [\"glm-5.2\", \"deepseek-v4-flash\"],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"kimi\": {\n \"modelMapping\": {\n \"codex\": \"kimi-k2.7\",\n \"gpt\": \"kimi-k2.7\"\n }\n },\n \"glm\": {\n \"modelMapping\": {\n \"codex\": \"glm-5.2\",\n \"gpt\": \"glm-5.2\"\n }\n },\n \"sensenova\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"noVisionModels\": [\"glm-5.2\", \"deepseek-v4-flash\"],\n \"visionFallbackModel\": \"sensenova-6.7-flash-lite\"\n },\n \"openrouter\": {\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"modelscope\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-ai/DeepSeek-V4-Flash\",\n \"gpt\": \"ZhipuAI/GLM-5.2\",\n \"mini\": \"deepseek-ai/DeepSeek-V4-Flash\"\n },\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"volc-ark\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\",\n \"minimax-m3\": \"xhigh\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false,\n \"noVisionModels\": [\"glm-5.2\", \"deepseek-v4-flash\"],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"qianfan\": {\n \"modelMapping\": {\n \"codex\": \"qianfan-code-latest\",\n \"gpt\": \"qianfan-code-latest\",\n \"mini\": \"qianfan-code-latest\"\n }\n },\n \"xfyun\": {\n \"modelMapping\": {\n \"codex\": \"astron-code-latest\",\n \"gpt\": \"astron-code-latest\",\n \"mini\": \"astron-code-latest\"\n },\n \"serviceType\": \"responses\"\n },\n \"github-copilot\": {\n \"serviceType\": \"copilot\",\n \"baseUrl\": \"https://api.githubcopilot.com\",\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n }\n }\n}\n"}, "4da6d77e15a5c2ac4bc15ce1ad1a68cfdbd2e2d0": {"t": 1785846876.0707269, "n": 4792, "h": "24b13f2fb887df84", "c": "{\n \"schemaVersion\": 1,\n \"providers\": {\n \"deepseek\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"deepseek-v4-pro\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"gpt\": \"max\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false,\n \"noVision\": true\n },\n \"mimo\": {\n \"modelMapping\": {\n \"codex\": \"mimo-v2.5-pro\",\n \"gpt\": \"mimo-v2.5-pro\",\n \"mini\": \"mimo-v2.5-pro\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"high\",\n \"mimo-v2.5\": \"high\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"serviceType\": \"responses\",\n \"codexToolCompat\": true,\n \"stripCodexClientTools\": true,\n \"noVisionModels\": [\"mimo-v2.5-pro\"],\n \"visionFallbackModel\": \"mimo-v2.5\",\n \"rateLimitRpm\": 80\n },\n \"compshare\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"MiniMax-M2.7\": \"xhigh\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false,\n \"noVisionModels\": [\"glm-5.2\", \"deepseek-v4-flash\"],\n \"visionFallbackModel\": \"MiniMax-M2.7\"\n },\n \"runapi\": {\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"unity2\": {\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"minimax\": {\n \"modelMapping\": {\n \"codex\": \"MiniMax-M2.7\",\n \"gpt\": \"MiniMax-M3\",\n \"mini\": \"MiniMax-M2.7\"\n },\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"dashscope\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"deepseek-v4-pro\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"gpt\": \"max\"\n }\n },\n \"opencode-zen\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"noVisionModels\": [\"glm-5.2\", \"deepseek-v4-flash\"],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"opencode-go\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"noVisionModels\": [\"glm-5.2\", \"deepseek-v4-flash\"],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"kimi\": {\n \"modelMapping\": {\n \"codex\": \"kimi-k2.7\",\n \"gpt\": \"kimi-k2.7\"\n }\n },\n \"glm\": {\n \"modelMapping\": {\n \"codex\": \"glm-5.2\",\n \"gpt\": \"glm-5.2\"\n }\n },\n \"sensenova\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"noVisionModels\": [\"glm-5.2\", \"deepseek-v4-flash\"],\n \"visionFallbackModel\": \"sensenova-6.7-flash-lite\"\n },\n \"openrouter\": {\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"modelscope\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-ai/DeepSeek-V4-Flash\",\n \"gpt\": \"ZhipuAI/GLM-5.2\",\n \"mini\": \"deepseek-ai/DeepSeek-V4-Flash\"\n },\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"volc-ark\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\",\n \"minimax-m3\": \"xhigh\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false,\n \"noVisionModels\": [\"glm-5.2\", \"deepseek-v4-flash\"],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"qianfan\": {\n \"modelMapping\": {\n \"codex\": \"qianfan-code-latest\",\n \"gpt\": \"qianfan-code-latest\",\n \"mini\": \"qianfan-code-latest\"\n }\n },\n \"xfyun\": {\n \"modelMapping\": {\n \"codex\": \"astron-code-latest\",\n \"gpt\": \"astron-code-latest\",\n \"mini\": \"astron-code-latest\"\n },\n \"serviceType\": \"responses\"\n },\n \"github-copilot\": {\n \"serviceType\": \"copilot\",\n \"baseUrl\": \"https://api.githubcopilot.com\",\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n }\n }\n}\n"}}, "shared/channel-presets/claude-messages.json": {"ebe33699466c4c93f605d8b16e5eb0567c127bd5": {"t": 1785674315.7379735, "n": 5520, "h": "966b3802e1cd02aa", "c": "{\n \"schemaVersion\": 1,\n \"providers\": {\n \"deepseek\": {\n \"modelMapping\": {\n \"fable\": \"deepseek-v4-pro\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"deepseek-v4-pro\",\n \"sonnet\": \"deepseek-v4-pro\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"normalizeMetadataUserId\": true,\n \"stripBillingHeader\": true,\n \"noVision\": true\n },\n \"mimo\": {\n \"modelMapping\": {\n \"fable\": \"mimo-v2.5-pro\",\n \"haiku\": \"mimo-v2.5-pro\",\n \"opus\": \"mimo-v2.5-pro\",\n \"sonnet\": \"mimo-v2.5-pro\"\n },\n \"reasoningMapping\": {\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"mimo-v2.5\": \"max\",\n \"opus\": \"max\",\n \"sonnet\": \"max\"\n },\n \"reasoningParamStyle\": \"thinking\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\"mimo-v2.5-pro\"],\n \"visionFallbackModel\": \"mimo-v2.5\",\n \"rateLimitRpm\": 80\n },\n \"compshare\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"reasoningMapping\": {\n \"MiniMax-M2.7\": \"xhigh\",\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"opus\": \"max\",\n \"sonnet\": \"max\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\"glm-5.2\", \"deepseek-v4-flash\"],\n \"visionFallbackModel\": \"MiniMax-M2.7\"\n },\n \"runapi\": {},\n \"unity2\": {},\n \"kimi\": {\n \"modelMapping\": {\n \"fable\": \"kimi-k2.6\",\n \"haiku\": \"kimi-k2.6\",\n \"opus\": \"kimi-k2.6\",\n \"sonnet\": \"kimi-k2.6\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"glm\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"glm-5.2\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"sensenova\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\"glm-5.2\", \"deepseek-v4-flash\"],\n \"visionFallbackModel\": \"sensenova-6.7-flash-lite\"\n },\n \"minimax\": {\n \"modelMapping\": {\n \"fable\": \"MiniMax-M3\",\n \"haiku\": \"MiniMax-M2.7\",\n \"opus\": \"MiniMax-M3\",\n \"sonnet\": \"MiniMax-M3\"\n },\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\"MiniMax-M2.7\"],\n \"visionFallbackModel\": \"MiniMax-M3\"\n },\n \"dashscope\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"glm-5.2\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"opencode-zen\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"minimax-m3\"\n },\n \"serviceType\": \"openai\",\n \"authHeader\": \"bearer\",\n \"reasoningMapping\": {\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"minimax-m3\": \"max\",\n \"opus\": \"max\",\n \"sonnet\": \"max\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\"glm-5.2\", \"deepseek-v4-flash\"],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"opencode-go\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"minimax-m3\"\n },\n \"serviceType\": \"openai\",\n \"authHeader\": \"bearer\",\n \"reasoningMapping\": {\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"minimax-m3\": \"max\",\n \"opus\": \"max\",\n \"sonnet\": \"max\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\"glm-5.2\", \"deepseek-v4-flash\"],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"modelscope\": {\n \"modelMapping\": {\n \"fable\": \"ZhipuAI/GLM-5.2\",\n \"haiku\": \"deepseek-ai/DeepSeek-V4-Flash\",\n \"opus\": \"ZhipuAI/GLM-5.2\",\n \"sonnet\": \"ZhipuAI/GLM-5.2\"\n },\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\"deepseek-ai/DeepSeek-V4-Flash\"],\n \"visionFallbackModel\": \"MiniMax/MiniMax-M2.7\"\n },\n \"volc-ark\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"reasoningMapping\": {\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"opus\": \"max\",\n \"sonnet\": \"max\",\n \"minimax-m3\": \"xhigh\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\"glm-5.2\", \"deepseek-v4-flash\"],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"qianfan\": {\n \"modelMapping\": {\n \"fable\": \"qianfan-code-latest\",\n \"haiku\": \"qianfan-code-latest\",\n \"opus\": \"qianfan-code-latest\",\n \"sonnet\": \"qianfan-code-latest\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"xfyun\": {\n \"modelMapping\": {\n \"fable\": \"astron-code-latest\",\n \"haiku\": \"astron-code-latest\",\n \"opus\": \"astron-code-latest\",\n \"sonnet\": \"astron-code-latest\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"tencent-lkeap\": {\n \"normalizeSystemRoleToTopLevel\": true\n }\n }\n}\n"}, "4da6d77e15a5c2ac4bc15ce1ad1a68cfdbd2e2d0": {"t": 1785846876.0843968, "n": 5520, "h": "966b3802e1cd02aa", "c": "{\n \"schemaVersion\": 1,\n \"providers\": {\n \"deepseek\": {\n \"modelMapping\": {\n \"fable\": \"deepseek-v4-pro\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"deepseek-v4-pro\",\n \"sonnet\": \"deepseek-v4-pro\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"normalizeMetadataUserId\": true,\n \"stripBillingHeader\": true,\n \"noVision\": true\n },\n \"mimo\": {\n \"modelMapping\": {\n \"fable\": \"mimo-v2.5-pro\",\n \"haiku\": \"mimo-v2.5-pro\",\n \"opus\": \"mimo-v2.5-pro\",\n \"sonnet\": \"mimo-v2.5-pro\"\n },\n \"reasoningMapping\": {\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"mimo-v2.5\": \"max\",\n \"opus\": \"max\",\n \"sonnet\": \"max\"\n },\n \"reasoningParamStyle\": \"thinking\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\"mimo-v2.5-pro\"],\n \"visionFallbackModel\": \"mimo-v2.5\",\n \"rateLimitRpm\": 80\n },\n \"compshare\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"reasoningMapping\": {\n \"MiniMax-M2.7\": \"xhigh\",\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"opus\": \"max\",\n \"sonnet\": \"max\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\"glm-5.2\", \"deepseek-v4-flash\"],\n \"visionFallbackModel\": \"MiniMax-M2.7\"\n },\n \"runapi\": {},\n \"unity2\": {},\n \"kimi\": {\n \"modelMapping\": {\n \"fable\": \"kimi-k2.6\",\n \"haiku\": \"kimi-k2.6\",\n \"opus\": \"kimi-k2.6\",\n \"sonnet\": \"kimi-k2.6\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"glm\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"glm-5.2\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"sensenova\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\"glm-5.2\", \"deepseek-v4-flash\"],\n \"visionFallbackModel\": \"sensenova-6.7-flash-lite\"\n },\n \"minimax\": {\n \"modelMapping\": {\n \"fable\": \"MiniMax-M3\",\n \"haiku\": \"MiniMax-M2.7\",\n \"opus\": \"MiniMax-M3\",\n \"sonnet\": \"MiniMax-M3\"\n },\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\"MiniMax-M2.7\"],\n \"visionFallbackModel\": \"MiniMax-M3\"\n },\n \"dashscope\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"glm-5.2\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"opencode-zen\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"minimax-m3\"\n },\n \"serviceType\": \"openai\",\n \"authHeader\": \"bearer\",\n \"reasoningMapping\": {\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"minimax-m3\": \"max\",\n \"opus\": \"max\",\n \"sonnet\": \"max\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\"glm-5.2\", \"deepseek-v4-flash\"],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"opencode-go\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"minimax-m3\"\n },\n \"serviceType\": \"openai\",\n \"authHeader\": \"bearer\",\n \"reasoningMapping\": {\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"minimax-m3\": \"max\",\n \"opus\": \"max\",\n \"sonnet\": \"max\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\"glm-5.2\", \"deepseek-v4-flash\"],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"modelscope\": {\n \"modelMapping\": {\n \"fable\": \"ZhipuAI/GLM-5.2\",\n \"haiku\": \"deepseek-ai/DeepSeek-V4-Flash\",\n \"opus\": \"ZhipuAI/GLM-5.2\",\n \"sonnet\": \"ZhipuAI/GLM-5.2\"\n },\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\"deepseek-ai/DeepSeek-V4-Flash\"],\n \"visionFallbackModel\": \"MiniMax/MiniMax-M2.7\"\n },\n \"volc-ark\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"reasoningMapping\": {\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"opus\": \"max\",\n \"sonnet\": \"max\",\n \"minimax-m3\": \"xhigh\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\"glm-5.2\", \"deepseek-v4-flash\"],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"qianfan\": {\n \"modelMapping\": {\n \"fable\": \"qianfan-code-latest\",\n \"haiku\": \"qianfan-code-latest\",\n \"opus\": \"qianfan-code-latest\",\n \"sonnet\": \"qianfan-code-latest\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"xfyun\": {\n \"modelMapping\": {\n \"fable\": \"astron-code-latest\",\n \"haiku\": \"astron-code-latest\",\n \"opus\": \"astron-code-latest\",\n \"sonnet\": \"astron-code-latest\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"tencent-lkeap\": {\n \"normalizeSystemRoleToTopLevel\": true\n }\n }\n}\n"}}, "docs/public/presets/channel-presets.json": {"ebe33699466c4c93f605d8b16e5eb0567c127bd5": {"t": 1785674316.2211366, "n": 14524, "h": "fea58c3f65ddf034", "c": "{\n \"schemaVersion\": 1,\n \"claudeMessages\": {\n \"schemaVersion\": 1,\n \"providers\": {\n \"deepseek\": {\n \"modelMapping\": {\n \"fable\": \"deepseek-v4-pro\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"deepseek-v4-pro\",\n \"sonnet\": \"deepseek-v4-pro\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"normalizeMetadataUserId\": true,\n \"stripBillingHeader\": true,\n \"noVision\": true\n },\n \"mimo\": {\n \"modelMapping\": {\n \"fable\": \"mimo-v2.5-pro\",\n \"haiku\": \"mimo-v2.5-pro\",\n \"opus\": \"mimo-v2.5-pro\",\n \"sonnet\": \"mimo-v2.5-pro\"\n },\n \"reasoningMapping\": {\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"mimo-v2.5\": \"max\",\n \"opus\": \"max\",\n \"sonnet\": \"max\"\n },\n \"reasoningParamStyle\": \"thinking\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"mimo-v2.5-pro\"\n ],\n \"visionFallbackModel\": \"mimo-v2.5\",\n \"rateLimitRpm\": 80\n },\n \"compshare\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"reasoningMapping\": {\n \"MiniMax-M2.7\": \"xhigh\",\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"opus\": \"max\",\n \"sonnet\": \"max\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"MiniMax-M2.7\"\n },\n \"runapi\": {},\n \"unity2\": {},\n \"kimi\": {\n \"modelMapping\": {\n \"fable\": \"kimi-k2.6\",\n \"haiku\": \"kimi-k2.6\",\n \"opus\": \"kimi-k2.6\",\n \"sonnet\": \"kimi-k2.6\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"glm\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"glm-5.2\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"sensenova\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"sensenova-6.7-flash-lite\"\n },\n \"minimax\": {\n \"modelMapping\": {\n \"fable\": \"MiniMax-M3\",\n \"haiku\": \"MiniMax-M2.7\",\n \"opus\": \"MiniMax-M3\",\n \"sonnet\": \"MiniMax-M3\"\n },\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"MiniMax-M2.7\"\n ],\n \"visionFallbackModel\": \"MiniMax-M3\"\n },\n \"dashscope\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"glm-5.2\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"opencode-zen\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"minimax-m3\"\n },\n \"serviceType\": \"openai\",\n \"authHeader\": \"bearer\",\n \"reasoningMapping\": {\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"minimax-m3\": \"max\",\n \"opus\": \"max\",\n \"sonnet\": \"max\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"opencode-go\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"minimax-m3\"\n },\n \"serviceType\": \"openai\",\n \"authHeader\": \"bearer\",\n \"reasoningMapping\": {\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"minimax-m3\": \"max\",\n \"opus\": \"max\",\n \"sonnet\": \"max\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"modelscope\": {\n \"modelMapping\": {\n \"fable\": \"ZhipuAI/GLM-5.2\",\n \"haiku\": \"deepseek-ai/DeepSeek-V4-Flash\",\n \"opus\": \"ZhipuAI/GLM-5.2\",\n \"sonnet\": \"ZhipuAI/GLM-5.2\"\n },\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"deepseek-ai/DeepSeek-V4-Flash\"\n ],\n \"visionFallbackModel\": \"MiniMax/MiniMax-M2.7\"\n },\n \"volc-ark\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"reasoningMapping\": {\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"opus\": \"max\",\n \"sonnet\": \"max\",\n \"minimax-m3\": \"xhigh\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"qianfan\": {\n \"modelMapping\": {\n \"fable\": \"qianfan-code-latest\",\n \"haiku\": \"qianfan-code-latest\",\n \"opus\": \"qianfan-code-latest\",\n \"sonnet\": \"qianfan-code-latest\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"xfyun\": {\n \"modelMapping\": {\n \"fable\": \"astron-code-latest\",\n \"haiku\": \"astron-code-latest\",\n \"opus\": \"astron-code-latest\",\n \"sonnet\": \"astron-code-latest\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"tencent-lkeap\": {\n \"normalizeSystemRoleToTopLevel\": true\n }\n }\n },\n \"openAIChat\": {\n \"schemaVersion\": 1,\n \"providers\": {\n \"deepseek\": {\n \"reasoningParamStyle\": \"reasoning\",\n \"noVision\": true\n },\n \"mimo\": {\n \"reasoningParamStyle\": \"thinking\",\n \"noVisionModels\": [\n \"mimo-v2.5-pro\"\n ],\n \"visionFallbackModel\": \"mimo-v2.5\",\n \"rateLimitRpm\": 80\n },\n \"compshare\": {\n \"reasoningParamStyle\": \"reasoning\",\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"MiniMax-M2.7\"\n },\n \"runapi\": {},\n \"unity2\": {},\n \"openrouter\": {},\n \"sensenova\": {\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"sensenova-6.7-flash-lite\"\n },\n \"modelscope\": {},\n \"minimax\": {},\n \"dashscope\": {},\n \"opencode-zen\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"authHeader\": \"bearer\",\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"opencode-go\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"authHeader\": \"bearer\",\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"volc-ark\": {},\n \"qianfan\": {},\n \"xfyun\": {\n \"modelMapping\": {\n \"codex\": \"astron-code-latest\",\n \"gpt\": \"astron-code-latest\",\n \"mini\": \"astron-code-latest\"\n }\n }\n }\n },\n \"codexResponses\": {\n \"schemaVersion\": 1,\n \"providers\": {\n \"deepseek\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"deepseek-v4-pro\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"gpt\": \"max\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false,\n \"noVision\": true\n },\n \"mimo\": {\n \"modelMapping\": {\n \"codex\": \"mimo-v2.5-pro\",\n \"gpt\": \"mimo-v2.5-pro\",\n \"mini\": \"mimo-v2.5-pro\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"high\",\n \"mimo-v2.5\": \"high\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"serviceType\": \"responses\",\n \"codexToolCompat\": true,\n \"stripCodexClientTools\": true,\n \"noVisionModels\": [\n \"mimo-v2.5-pro\"\n ],\n \"visionFallbackModel\": \"mimo-v2.5\",\n \"rateLimitRpm\": 80\n },\n \"compshare\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"MiniMax-M2.7\": \"xhigh\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false,\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"MiniMax-M2.7\"\n },\n \"runapi\": {\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"unity2\": {\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"minimax\": {\n \"modelMapping\": {\n \"codex\": \"MiniMax-M2.7\",\n \"gpt\": \"MiniMax-M3\",\n \"mini\": \"MiniMax-M2.7\"\n },\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"dashscope\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"deepseek-v4-pro\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"gpt\": \"max\"\n }\n },\n \"opencode-zen\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"opencode-go\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"kimi\": {\n \"modelMapping\": {\n \"codex\": \"kimi-k2.7\",\n \"gpt\": \"kimi-k2.7\"\n }\n },\n \"glm\": {\n \"modelMapping\": {\n \"codex\": \"glm-5.2\",\n \"gpt\": \"glm-5.2\"\n }\n },\n \"sensenova\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"sensenova-6.7-flash-lite\"\n },\n \"openrouter\": {\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"modelscope\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-ai/DeepSeek-V4-Flash\",\n \"gpt\": \"ZhipuAI/GLM-5.2\",\n \"mini\": \"deepseek-ai/DeepSeek-V4-Flash\"\n },\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"volc-ark\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\",\n \"minimax-m3\": \"xhigh\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false,\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"qianfan\": {\n \"modelMapping\": {\n \"codex\": \"qianfan-code-latest\",\n \"gpt\": \"qianfan-code-latest\",\n \"mini\": \"qianfan-code-latest\"\n }\n },\n \"xfyun\": {\n \"modelMapping\": {\n \"codex\": \"astron-code-latest\",\n \"gpt\": \"astron-code-latest\",\n \"mini\": \"astron-code-latest\"\n },\n \"serviceType\": \"responses\"\n },\n \"github-copilot\": {\n \"serviceType\": \"copilot\",\n \"baseUrl\": \"https://api.githubcopilot.com\",\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n }\n }\n },\n \"openAIMessages\": {\n \"schemaVersion\": 1,\n \"presets\": {\n \"gpt-5.5\": {\n \"modelMapping\": {\n \"fable\": \"gpt-5.5\",\n \"opus\": \"gpt-5.5\",\n \"sonnet\": \"gpt-5.4\",\n \"haiku\": \"gpt-5.4-mini\"\n },\n \"reasoningMapping\": {\n \"fable\": \"xhigh\",\n \"opus\": \"xhigh\",\n \"sonnet\": \"xhigh\",\n \"haiku\": \"high\"\n },\n \"fastMode\": true,\n \"textVerbosity\": \"medium\"\n },\n \"gpt-5.4\": {\n \"modelMapping\": {\n \"fable\": \"gpt-5.4\",\n \"opus\": \"gpt-5.4\",\n \"sonnet\": \"gpt-5.4\",\n \"haiku\": \"gpt-5.4-mini\"\n },\n \"reasoningMapping\": {\n \"fable\": \"xhigh\",\n \"opus\": \"xhigh\",\n \"sonnet\": \"xhigh\",\n \"haiku\": \"high\"\n },\n \"fastMode\": true,\n \"textVerbosity\": \"medium\"\n }\n }\n }\n}\n"}, "4da6d77e15a5c2ac4bc15ce1ad1a68cfdbd2e2d0": {"t": 1785846876.3907912, "n": 14524, "h": "fea58c3f65ddf034", "c": "{\n \"schemaVersion\": 1,\n \"claudeMessages\": {\n \"schemaVersion\": 1,\n \"providers\": {\n \"deepseek\": {\n \"modelMapping\": {\n \"fable\": \"deepseek-v4-pro\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"deepseek-v4-pro\",\n \"sonnet\": \"deepseek-v4-pro\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"normalizeMetadataUserId\": true,\n \"stripBillingHeader\": true,\n \"noVision\": true\n },\n \"mimo\": {\n \"modelMapping\": {\n \"fable\": \"mimo-v2.5-pro\",\n \"haiku\": \"mimo-v2.5-pro\",\n \"opus\": \"mimo-v2.5-pro\",\n \"sonnet\": \"mimo-v2.5-pro\"\n },\n \"reasoningMapping\": {\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"mimo-v2.5\": \"max\",\n \"opus\": \"max\",\n \"sonnet\": \"max\"\n },\n \"reasoningParamStyle\": \"thinking\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"mimo-v2.5-pro\"\n ],\n \"visionFallbackModel\": \"mimo-v2.5\",\n \"rateLimitRpm\": 80\n },\n \"compshare\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"reasoningMapping\": {\n \"MiniMax-M2.7\": \"xhigh\",\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"opus\": \"max\",\n \"sonnet\": \"max\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"MiniMax-M2.7\"\n },\n \"runapi\": {},\n \"unity2\": {},\n \"kimi\": {\n \"modelMapping\": {\n \"fable\": \"kimi-k2.6\",\n \"haiku\": \"kimi-k2.6\",\n \"opus\": \"kimi-k2.6\",\n \"sonnet\": \"kimi-k2.6\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"glm\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"glm-5.2\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"sensenova\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"sensenova-6.7-flash-lite\"\n },\n \"minimax\": {\n \"modelMapping\": {\n \"fable\": \"MiniMax-M3\",\n \"haiku\": \"MiniMax-M2.7\",\n \"opus\": \"MiniMax-M3\",\n \"sonnet\": \"MiniMax-M3\"\n },\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"MiniMax-M2.7\"\n ],\n \"visionFallbackModel\": \"MiniMax-M3\"\n },\n \"dashscope\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"glm-5.2\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"opencode-zen\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"minimax-m3\"\n },\n \"serviceType\": \"openai\",\n \"authHeader\": \"bearer\",\n \"reasoningMapping\": {\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"minimax-m3\": \"max\",\n \"opus\": \"max\",\n \"sonnet\": \"max\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"opencode-go\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"minimax-m3\"\n },\n \"serviceType\": \"openai\",\n \"authHeader\": \"bearer\",\n \"reasoningMapping\": {\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"minimax-m3\": \"max\",\n \"opus\": \"max\",\n \"sonnet\": \"max\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"modelscope\": {\n \"modelMapping\": {\n \"fable\": \"ZhipuAI/GLM-5.2\",\n \"haiku\": \"deepseek-ai/DeepSeek-V4-Flash\",\n \"opus\": \"ZhipuAI/GLM-5.2\",\n \"sonnet\": \"ZhipuAI/GLM-5.2\"\n },\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"deepseek-ai/DeepSeek-V4-Flash\"\n ],\n \"visionFallbackModel\": \"MiniMax/MiniMax-M2.7\"\n },\n \"volc-ark\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"reasoningMapping\": {\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"opus\": \"max\",\n \"sonnet\": \"max\",\n \"minimax-m3\": \"xhigh\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"qianfan\": {\n \"modelMapping\": {\n \"fable\": \"qianfan-code-latest\",\n \"haiku\": \"qianfan-code-latest\",\n \"opus\": \"qianfan-code-latest\",\n \"sonnet\": \"qianfan-code-latest\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"xfyun\": {\n \"modelMapping\": {\n \"fable\": \"astron-code-latest\",\n \"haiku\": \"astron-code-latest\",\n \"opus\": \"astron-code-latest\",\n \"sonnet\": \"astron-code-latest\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"tencent-lkeap\": {\n \"normalizeSystemRoleToTopLevel\": true\n }\n }\n },\n \"openAIChat\": {\n \"schemaVersion\": 1,\n \"providers\": {\n \"deepseek\": {\n \"reasoningParamStyle\": \"reasoning\",\n \"noVision\": true\n },\n \"mimo\": {\n \"reasoningParamStyle\": \"thinking\",\n \"noVisionModels\": [\n \"mimo-v2.5-pro\"\n ],\n \"visionFallbackModel\": \"mimo-v2.5\",\n \"rateLimitRpm\": 80\n },\n \"compshare\": {\n \"reasoningParamStyle\": \"reasoning\",\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"MiniMax-M2.7\"\n },\n \"runapi\": {},\n \"unity2\": {},\n \"openrouter\": {},\n \"sensenova\": {\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"sensenova-6.7-flash-lite\"\n },\n \"modelscope\": {},\n \"minimax\": {},\n \"dashscope\": {},\n \"opencode-zen\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"authHeader\": \"bearer\",\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"opencode-go\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"authHeader\": \"bearer\",\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"volc-ark\": {},\n \"qianfan\": {},\n \"xfyun\": {\n \"modelMapping\": {\n \"codex\": \"astron-code-latest\",\n \"gpt\": \"astron-code-latest\",\n \"mini\": \"astron-code-latest\"\n }\n }\n }\n },\n \"codexResponses\": {\n \"schemaVersion\": 1,\n \"providers\": {\n \"deepseek\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"deepseek-v4-pro\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"gpt\": \"max\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false,\n \"noVision\": true\n },\n \"mimo\": {\n \"modelMapping\": {\n \"codex\": \"mimo-v2.5-pro\",\n \"gpt\": \"mimo-v2.5-pro\",\n \"mini\": \"mimo-v2.5-pro\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"high\",\n \"mimo-v2.5\": \"high\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"serviceType\": \"responses\",\n \"codexToolCompat\": true,\n \"stripCodexClientTools\": true,\n \"noVisionModels\": [\n \"mimo-v2.5-pro\"\n ],\n \"visionFallbackModel\": \"mimo-v2.5\",\n \"rateLimitRpm\": 80\n },\n \"compshare\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"MiniMax-M2.7\": \"xhigh\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false,\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"MiniMax-M2.7\"\n },\n \"runapi\": {\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"unity2\": {\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"minimax\": {\n \"modelMapping\": {\n \"codex\": \"MiniMax-M2.7\",\n \"gpt\": \"MiniMax-M3\",\n \"mini\": \"MiniMax-M2.7\"\n },\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"dashscope\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"deepseek-v4-pro\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"gpt\": \"max\"\n }\n },\n \"opencode-zen\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"opencode-go\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"kimi\": {\n \"modelMapping\": {\n \"codex\": \"kimi-k2.7\",\n \"gpt\": \"kimi-k2.7\"\n }\n },\n \"glm\": {\n \"modelMapping\": {\n \"codex\": \"glm-5.2\",\n \"gpt\": \"glm-5.2\"\n }\n },\n \"sensenova\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"sensenova-6.7-flash-lite\"\n },\n \"openrouter\": {\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"modelscope\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-ai/DeepSeek-V4-Flash\",\n \"gpt\": \"ZhipuAI/GLM-5.2\",\n \"mini\": \"deepseek-ai/DeepSeek-V4-Flash\"\n },\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"volc-ark\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\",\n \"minimax-m3\": \"xhigh\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false,\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"qianfan\": {\n \"modelMapping\": {\n \"codex\": \"qianfan-code-latest\",\n \"gpt\": \"qianfan-code-latest\",\n \"mini\": \"qianfan-code-latest\"\n }\n },\n \"xfyun\": {\n \"modelMapping\": {\n \"codex\": \"astron-code-latest\",\n \"gpt\": \"astron-code-latest\",\n \"mini\": \"astron-code-latest\"\n },\n \"serviceType\": \"responses\"\n },\n \"github-copilot\": {\n \"serviceType\": \"copilot\",\n \"baseUrl\": \"https://api.githubcopilot.com\",\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n }\n }\n },\n \"openAIMessages\": {\n \"schemaVersion\": 1,\n \"presets\": {\n \"gpt-5.5\": {\n \"modelMapping\": {\n \"fable\": \"gpt-5.5\",\n \"opus\": \"gpt-5.5\",\n \"sonnet\": \"gpt-5.4\",\n \"haiku\": \"gpt-5.4-mini\"\n },\n \"reasoningMapping\": {\n \"fable\": \"xhigh\",\n \"opus\": \"xhigh\",\n \"sonnet\": \"xhigh\",\n \"haiku\": \"high\"\n },\n \"fastMode\": true,\n \"textVerbosity\": \"medium\"\n },\n \"gpt-5.4\": {\n \"modelMapping\": {\n \"fable\": \"gpt-5.4\",\n \"opus\": \"gpt-5.4\",\n \"sonnet\": \"gpt-5.4\",\n \"haiku\": \"gpt-5.4-mini\"\n },\n \"reasoningMapping\": {\n \"fable\": \"xhigh\",\n \"opus\": \"xhigh\",\n \"sonnet\": \"xhigh\",\n \"haiku\": \"high\"\n },\n \"fastMode\": true,\n \"textVerbosity\": \"medium\"\n }\n }\n }\n}\n"}}, "backend-go/internal/presetstore/embedded/channel-presets.json": {"ebe33699466c4c93f605d8b16e5eb0567c127bd5": {"t": 1785674316.5619855, "n": 14524, "h": "fea58c3f65ddf034", "c": "{\n \"schemaVersion\": 1,\n \"claudeMessages\": {\n \"schemaVersion\": 1,\n \"providers\": {\n \"deepseek\": {\n \"modelMapping\": {\n \"fable\": \"deepseek-v4-pro\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"deepseek-v4-pro\",\n \"sonnet\": \"deepseek-v4-pro\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"normalizeMetadataUserId\": true,\n \"stripBillingHeader\": true,\n \"noVision\": true\n },\n \"mimo\": {\n \"modelMapping\": {\n \"fable\": \"mimo-v2.5-pro\",\n \"haiku\": \"mimo-v2.5-pro\",\n \"opus\": \"mimo-v2.5-pro\",\n \"sonnet\": \"mimo-v2.5-pro\"\n },\n \"reasoningMapping\": {\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"mimo-v2.5\": \"max\",\n \"opus\": \"max\",\n \"sonnet\": \"max\"\n },\n \"reasoningParamStyle\": \"thinking\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"mimo-v2.5-pro\"\n ],\n \"visionFallbackModel\": \"mimo-v2.5\",\n \"rateLimitRpm\": 80\n },\n \"compshare\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"reasoningMapping\": {\n \"MiniMax-M2.7\": \"xhigh\",\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"opus\": \"max\",\n \"sonnet\": \"max\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"MiniMax-M2.7\"\n },\n \"runapi\": {},\n \"unity2\": {},\n \"kimi\": {\n \"modelMapping\": {\n \"fable\": \"kimi-k2.6\",\n \"haiku\": \"kimi-k2.6\",\n \"opus\": \"kimi-k2.6\",\n \"sonnet\": \"kimi-k2.6\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"glm\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"glm-5.2\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"sensenova\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"sensenova-6.7-flash-lite\"\n },\n \"minimax\": {\n \"modelMapping\": {\n \"fable\": \"MiniMax-M3\",\n \"haiku\": \"MiniMax-M2.7\",\n \"opus\": \"MiniMax-M3\",\n \"sonnet\": \"MiniMax-M3\"\n },\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"MiniMax-M2.7\"\n ],\n \"visionFallbackModel\": \"MiniMax-M3\"\n },\n \"dashscope\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"glm-5.2\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"opencode-zen\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"minimax-m3\"\n },\n \"serviceType\": \"openai\",\n \"authHeader\": \"bearer\",\n \"reasoningMapping\": {\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"minimax-m3\": \"max\",\n \"opus\": \"max\",\n \"sonnet\": \"max\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"opencode-go\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"minimax-m3\"\n },\n \"serviceType\": \"openai\",\n \"authHeader\": \"bearer\",\n \"reasoningMapping\": {\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"minimax-m3\": \"max\",\n \"opus\": \"max\",\n \"sonnet\": \"max\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"modelscope\": {\n \"modelMapping\": {\n \"fable\": \"ZhipuAI/GLM-5.2\",\n \"haiku\": \"deepseek-ai/DeepSeek-V4-Flash\",\n \"opus\": \"ZhipuAI/GLM-5.2\",\n \"sonnet\": \"ZhipuAI/GLM-5.2\"\n },\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"deepseek-ai/DeepSeek-V4-Flash\"\n ],\n \"visionFallbackModel\": \"MiniMax/MiniMax-M2.7\"\n },\n \"volc-ark\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"reasoningMapping\": {\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"opus\": \"max\",\n \"sonnet\": \"max\",\n \"minimax-m3\": \"xhigh\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"qianfan\": {\n \"modelMapping\": {\n \"fable\": \"qianfan-code-latest\",\n \"haiku\": \"qianfan-code-latest\",\n \"opus\": \"qianfan-code-latest\",\n \"sonnet\": \"qianfan-code-latest\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"xfyun\": {\n \"modelMapping\": {\n \"fable\": \"astron-code-latest\",\n \"haiku\": \"astron-code-latest\",\n \"opus\": \"astron-code-latest\",\n \"sonnet\": \"astron-code-latest\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"tencent-lkeap\": {\n \"normalizeSystemRoleToTopLevel\": true\n }\n }\n },\n \"openAIChat\": {\n \"schemaVersion\": 1,\n \"providers\": {\n \"deepseek\": {\n \"reasoningParamStyle\": \"reasoning\",\n \"noVision\": true\n },\n \"mimo\": {\n \"reasoningParamStyle\": \"thinking\",\n \"noVisionModels\": [\n \"mimo-v2.5-pro\"\n ],\n \"visionFallbackModel\": \"mimo-v2.5\",\n \"rateLimitRpm\": 80\n },\n \"compshare\": {\n \"reasoningParamStyle\": \"reasoning\",\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"MiniMax-M2.7\"\n },\n \"runapi\": {},\n \"unity2\": {},\n \"openrouter\": {},\n \"sensenova\": {\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"sensenova-6.7-flash-lite\"\n },\n \"modelscope\": {},\n \"minimax\": {},\n \"dashscope\": {},\n \"opencode-zen\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"authHeader\": \"bearer\",\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"opencode-go\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"authHeader\": \"bearer\",\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"volc-ark\": {},\n \"qianfan\": {},\n \"xfyun\": {\n \"modelMapping\": {\n \"codex\": \"astron-code-latest\",\n \"gpt\": \"astron-code-latest\",\n \"mini\": \"astron-code-latest\"\n }\n }\n }\n },\n \"codexResponses\": {\n \"schemaVersion\": 1,\n \"providers\": {\n \"deepseek\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"deepseek-v4-pro\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"gpt\": \"max\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false,\n \"noVision\": true\n },\n \"mimo\": {\n \"modelMapping\": {\n \"codex\": \"mimo-v2.5-pro\",\n \"gpt\": \"mimo-v2.5-pro\",\n \"mini\": \"mimo-v2.5-pro\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"high\",\n \"mimo-v2.5\": \"high\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"serviceType\": \"responses\",\n \"codexToolCompat\": true,\n \"stripCodexClientTools\": true,\n \"noVisionModels\": [\n \"mimo-v2.5-pro\"\n ],\n \"visionFallbackModel\": \"mimo-v2.5\",\n \"rateLimitRpm\": 80\n },\n \"compshare\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"MiniMax-M2.7\": \"xhigh\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false,\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"MiniMax-M2.7\"\n },\n \"runapi\": {\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"unity2\": {\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"minimax\": {\n \"modelMapping\": {\n \"codex\": \"MiniMax-M2.7\",\n \"gpt\": \"MiniMax-M3\",\n \"mini\": \"MiniMax-M2.7\"\n },\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"dashscope\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"deepseek-v4-pro\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"gpt\": \"max\"\n }\n },\n \"opencode-zen\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"opencode-go\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"kimi\": {\n \"modelMapping\": {\n \"codex\": \"kimi-k2.7\",\n \"gpt\": \"kimi-k2.7\"\n }\n },\n \"glm\": {\n \"modelMapping\": {\n \"codex\": \"glm-5.2\",\n \"gpt\": \"glm-5.2\"\n }\n },\n \"sensenova\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"sensenova-6.7-flash-lite\"\n },\n \"openrouter\": {\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"modelscope\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-ai/DeepSeek-V4-Flash\",\n \"gpt\": \"ZhipuAI/GLM-5.2\",\n \"mini\": \"deepseek-ai/DeepSeek-V4-Flash\"\n },\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"volc-ark\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\",\n \"minimax-m3\": \"xhigh\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false,\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"qianfan\": {\n \"modelMapping\": {\n \"codex\": \"qianfan-code-latest\",\n \"gpt\": \"qianfan-code-latest\",\n \"mini\": \"qianfan-code-latest\"\n }\n },\n \"xfyun\": {\n \"modelMapping\": {\n \"codex\": \"astron-code-latest\",\n \"gpt\": \"astron-code-latest\",\n \"mini\": \"astron-code-latest\"\n },\n \"serviceType\": \"responses\"\n },\n \"github-copilot\": {\n \"serviceType\": \"copilot\",\n \"baseUrl\": \"https://api.githubcopilot.com\",\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n }\n }\n },\n \"openAIMessages\": {\n \"schemaVersion\": 1,\n \"presets\": {\n \"gpt-5.5\": {\n \"modelMapping\": {\n \"fable\": \"gpt-5.5\",\n \"opus\": \"gpt-5.5\",\n \"sonnet\": \"gpt-5.4\",\n \"haiku\": \"gpt-5.4-mini\"\n },\n \"reasoningMapping\": {\n \"fable\": \"xhigh\",\n \"opus\": \"xhigh\",\n \"sonnet\": \"xhigh\",\n \"haiku\": \"high\"\n },\n \"fastMode\": true,\n \"textVerbosity\": \"medium\"\n },\n \"gpt-5.4\": {\n \"modelMapping\": {\n \"fable\": \"gpt-5.4\",\n \"opus\": \"gpt-5.4\",\n \"sonnet\": \"gpt-5.4\",\n \"haiku\": \"gpt-5.4-mini\"\n },\n \"reasoningMapping\": {\n \"fable\": \"xhigh\",\n \"opus\": \"xhigh\",\n \"sonnet\": \"xhigh\",\n \"haiku\": \"high\"\n },\n \"fastMode\": true,\n \"textVerbosity\": \"medium\"\n }\n }\n }\n}\n"}, "4da6d77e15a5c2ac4bc15ce1ad1a68cfdbd2e2d0": {"t": 1785846876.3001852, "n": 14524, "h": "fea58c3f65ddf034", "c": "{\n \"schemaVersion\": 1,\n \"claudeMessages\": {\n \"schemaVersion\": 1,\n \"providers\": {\n \"deepseek\": {\n \"modelMapping\": {\n \"fable\": \"deepseek-v4-pro\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"deepseek-v4-pro\",\n \"sonnet\": \"deepseek-v4-pro\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"normalizeMetadataUserId\": true,\n \"stripBillingHeader\": true,\n \"noVision\": true\n },\n \"mimo\": {\n \"modelMapping\": {\n \"fable\": \"mimo-v2.5-pro\",\n \"haiku\": \"mimo-v2.5-pro\",\n \"opus\": \"mimo-v2.5-pro\",\n \"sonnet\": \"mimo-v2.5-pro\"\n },\n \"reasoningMapping\": {\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"mimo-v2.5\": \"max\",\n \"opus\": \"max\",\n \"sonnet\": \"max\"\n },\n \"reasoningParamStyle\": \"thinking\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"mimo-v2.5-pro\"\n ],\n \"visionFallbackModel\": \"mimo-v2.5\",\n \"rateLimitRpm\": 80\n },\n \"compshare\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"reasoningMapping\": {\n \"MiniMax-M2.7\": \"xhigh\",\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"opus\": \"max\",\n \"sonnet\": \"max\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"MiniMax-M2.7\"\n },\n \"runapi\": {},\n \"unity2\": {},\n \"kimi\": {\n \"modelMapping\": {\n \"fable\": \"kimi-k2.6\",\n \"haiku\": \"kimi-k2.6\",\n \"opus\": \"kimi-k2.6\",\n \"sonnet\": \"kimi-k2.6\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"glm\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"glm-5.2\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"sensenova\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"sensenova-6.7-flash-lite\"\n },\n \"minimax\": {\n \"modelMapping\": {\n \"fable\": \"MiniMax-M3\",\n \"haiku\": \"MiniMax-M2.7\",\n \"opus\": \"MiniMax-M3\",\n \"sonnet\": \"MiniMax-M3\"\n },\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"MiniMax-M2.7\"\n ],\n \"visionFallbackModel\": \"MiniMax-M3\"\n },\n \"dashscope\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"glm-5.2\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"opencode-zen\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"minimax-m3\"\n },\n \"serviceType\": \"openai\",\n \"authHeader\": \"bearer\",\n \"reasoningMapping\": {\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"minimax-m3\": \"max\",\n \"opus\": \"max\",\n \"sonnet\": \"max\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"opencode-go\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"minimax-m3\"\n },\n \"serviceType\": \"openai\",\n \"authHeader\": \"bearer\",\n \"reasoningMapping\": {\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"minimax-m3\": \"max\",\n \"opus\": \"max\",\n \"sonnet\": \"max\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"modelscope\": {\n \"modelMapping\": {\n \"fable\": \"ZhipuAI/GLM-5.2\",\n \"haiku\": \"deepseek-ai/DeepSeek-V4-Flash\",\n \"opus\": \"ZhipuAI/GLM-5.2\",\n \"sonnet\": \"ZhipuAI/GLM-5.2\"\n },\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"deepseek-ai/DeepSeek-V4-Flash\"\n ],\n \"visionFallbackModel\": \"MiniMax/MiniMax-M2.7\"\n },\n \"volc-ark\": {\n \"modelMapping\": {\n \"fable\": \"glm-5.2\",\n \"haiku\": \"deepseek-v4-flash\",\n \"opus\": \"glm-5.2\",\n \"sonnet\": \"glm-5.2\"\n },\n \"reasoningMapping\": {\n \"fable\": \"max\",\n \"haiku\": \"high\",\n \"opus\": \"max\",\n \"sonnet\": \"max\",\n \"minimax-m3\": \"xhigh\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"normalizeSystemRoleToTopLevel\": true,\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"qianfan\": {\n \"modelMapping\": {\n \"fable\": \"qianfan-code-latest\",\n \"haiku\": \"qianfan-code-latest\",\n \"opus\": \"qianfan-code-latest\",\n \"sonnet\": \"qianfan-code-latest\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"xfyun\": {\n \"modelMapping\": {\n \"fable\": \"astron-code-latest\",\n \"haiku\": \"astron-code-latest\",\n \"opus\": \"astron-code-latest\",\n \"sonnet\": \"astron-code-latest\"\n },\n \"normalizeSystemRoleToTopLevel\": true\n },\n \"tencent-lkeap\": {\n \"normalizeSystemRoleToTopLevel\": true\n }\n }\n },\n \"openAIChat\": {\n \"schemaVersion\": 1,\n \"providers\": {\n \"deepseek\": {\n \"reasoningParamStyle\": \"reasoning\",\n \"noVision\": true\n },\n \"mimo\": {\n \"reasoningParamStyle\": \"thinking\",\n \"noVisionModels\": [\n \"mimo-v2.5-pro\"\n ],\n \"visionFallbackModel\": \"mimo-v2.5\",\n \"rateLimitRpm\": 80\n },\n \"compshare\": {\n \"reasoningParamStyle\": \"reasoning\",\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"MiniMax-M2.7\"\n },\n \"runapi\": {},\n \"unity2\": {},\n \"openrouter\": {},\n \"sensenova\": {\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"sensenova-6.7-flash-lite\"\n },\n \"modelscope\": {},\n \"minimax\": {},\n \"dashscope\": {},\n \"opencode-zen\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"authHeader\": \"bearer\",\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"opencode-go\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"authHeader\": \"bearer\",\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"volc-ark\": {},\n \"qianfan\": {},\n \"xfyun\": {\n \"modelMapping\": {\n \"codex\": \"astron-code-latest\",\n \"gpt\": \"astron-code-latest\",\n \"mini\": \"astron-code-latest\"\n }\n }\n }\n },\n \"codexResponses\": {\n \"schemaVersion\": 1,\n \"providers\": {\n \"deepseek\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"deepseek-v4-pro\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"gpt\": \"max\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false,\n \"noVision\": true\n },\n \"mimo\": {\n \"modelMapping\": {\n \"codex\": \"mimo-v2.5-pro\",\n \"gpt\": \"mimo-v2.5-pro\",\n \"mini\": \"mimo-v2.5-pro\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"high\",\n \"mimo-v2.5\": \"high\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"serviceType\": \"responses\",\n \"codexToolCompat\": true,\n \"stripCodexClientTools\": true,\n \"noVisionModels\": [\n \"mimo-v2.5-pro\"\n ],\n \"visionFallbackModel\": \"mimo-v2.5\",\n \"rateLimitRpm\": 80\n },\n \"compshare\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"MiniMax-M2.7\": \"xhigh\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false,\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"MiniMax-M2.7\"\n },\n \"runapi\": {\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"unity2\": {\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"minimax\": {\n \"modelMapping\": {\n \"codex\": \"MiniMax-M2.7\",\n \"gpt\": \"MiniMax-M3\",\n \"mini\": \"MiniMax-M2.7\"\n },\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"dashscope\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"deepseek-v4-pro\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"gpt\": \"max\"\n }\n },\n \"opencode-zen\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"opencode-go\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"kimi\": {\n \"modelMapping\": {\n \"codex\": \"kimi-k2.7\",\n \"gpt\": \"kimi-k2.7\"\n }\n },\n \"glm\": {\n \"modelMapping\": {\n \"codex\": \"glm-5.2\",\n \"gpt\": \"glm-5.2\"\n }\n },\n \"sensenova\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"sensenova-6.7-flash-lite\"\n },\n \"openrouter\": {\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"modelscope\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-ai/DeepSeek-V4-Flash\",\n \"gpt\": \"ZhipuAI/GLM-5.2\",\n \"mini\": \"deepseek-ai/DeepSeek-V4-Flash\"\n },\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n },\n \"volc-ark\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\",\n \"minimax-m3\": \"xhigh\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false,\n \"noVisionModels\": [\n \"glm-5.2\",\n \"deepseek-v4-flash\"\n ],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"qianfan\": {\n \"modelMapping\": {\n \"codex\": \"qianfan-code-latest\",\n \"gpt\": \"qianfan-code-latest\",\n \"mini\": \"qianfan-code-latest\"\n }\n },\n \"xfyun\": {\n \"modelMapping\": {\n \"codex\": \"astron-code-latest\",\n \"gpt\": \"astron-code-latest\",\n \"mini\": \"astron-code-latest\"\n },\n \"serviceType\": \"responses\"\n },\n \"github-copilot\": {\n \"serviceType\": \"copilot\",\n \"baseUrl\": \"https://api.githubcopilot.com\",\n \"codexToolCompat\": false,\n \"stripCodexClientTools\": false\n }\n }\n },\n \"openAIMessages\": {\n \"schemaVersion\": 1,\n \"presets\": {\n \"gpt-5.5\": {\n \"modelMapping\": {\n \"fable\": \"gpt-5.5\",\n \"opus\": \"gpt-5.5\",\n \"sonnet\": \"gpt-5.4\",\n \"haiku\": \"gpt-5.4-mini\"\n },\n \"reasoningMapping\": {\n \"fable\": \"xhigh\",\n \"opus\": \"xhigh\",\n \"sonnet\": \"xhigh\",\n \"haiku\": \"high\"\n },\n \"fastMode\": true,\n \"textVerbosity\": \"medium\"\n },\n \"gpt-5.4\": {\n \"modelMapping\": {\n \"fable\": \"gpt-5.4\",\n \"opus\": \"gpt-5.4\",\n \"sonnet\": \"gpt-5.4\",\n \"haiku\": \"gpt-5.4-mini\"\n },\n \"reasoningMapping\": {\n \"fable\": \"xhigh\",\n \"opus\": \"xhigh\",\n \"sonnet\": \"xhigh\",\n \"haiku\": \"high\"\n },\n \"fastMode\": true,\n \"textVerbosity\": \"medium\"\n }\n }\n }\n}\n"}}, "shared/channel-presets/openai-chat.json": {"ebe33699466c4c93f605d8b16e5eb0567c127bd5": {"t": 1785674316.8188396, "n": 1825, "h": "42dc3add833503d9", "c": "{\n \"schemaVersion\": 1,\n \"providers\": {\n \"deepseek\": {\n \"reasoningParamStyle\": \"reasoning\",\n \"noVision\": true\n },\n \"mimo\": {\n \"reasoningParamStyle\": \"thinking\",\n \"noVisionModels\": [\"mimo-v2.5-pro\"],\n \"visionFallbackModel\": \"mimo-v2.5\",\n \"rateLimitRpm\": 80\n },\n \"compshare\": {\n \"reasoningParamStyle\": \"reasoning\",\n \"noVisionModels\": [\"glm-5.2\", \"deepseek-v4-flash\"],\n \"visionFallbackModel\": \"MiniMax-M2.7\"\n },\n \"runapi\": {},\n \"unity2\": {},\n \"openrouter\": {},\n \"sensenova\": {\n \"noVisionModels\": [\"glm-5.2\", \"deepseek-v4-flash\"],\n \"visionFallbackModel\": \"sensenova-6.7-flash-lite\"\n },\n \"modelscope\": {},\n \"minimax\": {},\n \"dashscope\": {},\n \"opencode-zen\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"authHeader\": \"bearer\",\n \"noVisionModels\": [\"glm-5.2\", \"deepseek-v4-flash\"],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"opencode-go\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"authHeader\": \"bearer\",\n \"noVisionModels\": [\"glm-5.2\", \"deepseek-v4-flash\"],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"volc-ark\": {},\n \"qianfan\": {},\n \"xfyun\": {\n \"modelMapping\": {\n \"codex\": \"astron-code-latest\",\n \"gpt\": \"astron-code-latest\",\n \"mini\": \"astron-code-latest\"\n }\n }\n }\n}\n"}, "4da6d77e15a5c2ac4bc15ce1ad1a68cfdbd2e2d0": {"t": 1785846876.293159, "n": 1825, "h": "42dc3add833503d9", "c": "{\n \"schemaVersion\": 1,\n \"providers\": {\n \"deepseek\": {\n \"reasoningParamStyle\": \"reasoning\",\n \"noVision\": true\n },\n \"mimo\": {\n \"reasoningParamStyle\": \"thinking\",\n \"noVisionModels\": [\"mimo-v2.5-pro\"],\n \"visionFallbackModel\": \"mimo-v2.5\",\n \"rateLimitRpm\": 80\n },\n \"compshare\": {\n \"reasoningParamStyle\": \"reasoning\",\n \"noVisionModels\": [\"glm-5.2\", \"deepseek-v4-flash\"],\n \"visionFallbackModel\": \"MiniMax-M2.7\"\n },\n \"runapi\": {},\n \"unity2\": {},\n \"openrouter\": {},\n \"sensenova\": {\n \"noVisionModels\": [\"glm-5.2\", \"deepseek-v4-flash\"],\n \"visionFallbackModel\": \"sensenova-6.7-flash-lite\"\n },\n \"modelscope\": {},\n \"minimax\": {},\n \"dashscope\": {},\n \"opencode-zen\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"authHeader\": \"bearer\",\n \"noVisionModels\": [\"glm-5.2\", \"deepseek-v4-flash\"],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"opencode-go\": {\n \"modelMapping\": {\n \"codex\": \"deepseek-v4-flash\",\n \"gpt\": \"glm-5.2\",\n \"mini\": \"deepseek-v4-flash\"\n },\n \"reasoningMapping\": {\n \"codex\": \"high\",\n \"gpt\": \"max\",\n \"mini\": \"high\"\n },\n \"reasoningParamStyle\": \"reasoning\",\n \"authHeader\": \"bearer\",\n \"noVisionModels\": [\"glm-5.2\", \"deepseek-v4-flash\"],\n \"visionFallbackModel\": \"minimax-m3\"\n },\n \"volc-ark\": {},\n \"qianfan\": {},\n \"xfyun\": {\n \"modelMapping\": {\n \"codex\": \"astron-code-latest\",\n \"gpt\": \"astron-code-latest\",\n \"mini\": \"astron-code-latest\"\n }\n }\n }\n}\n"}}, "backend-go/internal/autopilot/handlers_auto_managed_test.go": {"ebe33699466c4c93f605d8b16e5eb0567c127bd5": {"t": 1785674681.673177, "n": 68677, "h": "9a46edf960bd44dc", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/BenedictKing/ccx/internal/errutil\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestListAccountsMasksCredentials(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-secret-value\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil)\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"sk-secret-value\") {\n\t\tt.Fatalf(\"账号列表泄露明文 Key: %s\", body)\n\t}\n\tif !strings.Contains(body, \"cred_test\") || !strings.Contains(body, \"keyMask\") {\n\t\tt.Fatalf(\"账号列表缺少凭证掩码信息: %s\", body)\n\t}\n}\n\nfunc TestListAccountsIncludesActiveEndpointModelAvailability(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"volcengine\",\"name\":\"volcengine\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"ark-secret-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"ark-secret-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"volcengine\",\"name\":\"volcengine-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://example.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tstore, err := NewProfileStoreWithDB(newTestDB(t))\n\tif err != nil {\n\t\tt.Fatalf(\"NewProfileStoreWithDB 失败: %v\", err)\n\t}\n\tupdatedAt := time.Date(2026, 7, 17, 10, 54, 33, 0, time.UTC)\n\tdiscoveredAt := updatedAt\n\tdiscoveredAtLater := updatedAt.Add(time.Minute)\n\tfor _, profile := range []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_a\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_a\", KeyMask: \"ark-a***001\", AvailableModels: []string{\"model-b\", \"model-a\"}, UpdatedAt: updatedAt,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI, ModelsDiscoveredAt: &discoveredAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_b\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_b\", KeyMask: \"ark-b***002\", AvailableModels: []string{\"model-c\", \"model-b\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI, ModelsDiscoveredAt: &discoveredAtLater,\n\t\t},\n\t} {\n\t\tif err := store.Upsert(profile); err != nil {\n\t\t\tt.Fatalf(\"ProfileStore.Upsert 失败: %v\", err)\n\t\t}\n\t}\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager, Runner: runner})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response struct {\n\t\tAccounts []managedAccountView `json:\"accounts\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatalf(\"解析响应失败: %v\", err)\n\t}\n\tif len(response.Accounts) != 1 || len(response.Accounts[0].Channels) != 1 {\n\t\tt.Fatalf(\"账号渠道数量错误: %+v\", response.Accounts)\n\t}\n\tchannel := response.Accounts[0].Channels[0]\n\tif got, want := strings.Join(channel.DiscoveredModels, \",\"), \"model-a,model-b,model-c\"; got != want {\n\t\tt.Fatalf(\"DiscoveredModels=%q, want %q\", got, want)\n\t}\n\tif !channel.ModelInventoryKnown {\n\t\tt.Fatal(\"已发现模型时 ModelInventoryKnown 应为 true\")\n\t}\n\tif len(channel.ModelBindings) != 2 {\n\t\tt.Fatalf(\"ModelBindings=%+v, want 2\", channel.ModelBindings)\n\t}\n\tif got := strings.Join(channel.ModelBindings[0].Models, \",\"); got != \"model-a,model-b\" {\n\t\tt.Fatalf(\"第一个 Key 模型=%q\", got)\n\t}\n\tif channel.ModelsUpdatedAt == nil || !channel.ModelsUpdatedAt.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"ModelsUpdatedAt=%v\", channel.ModelsUpdatedAt)\n\t}\n\tif strings.Contains(w.Body.String(), \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", w.Body.String())\n\t}\n}\n\nfunc TestListAccountsIncludesCustomAutoManagedProtocolAvailability(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [],\n \"upstream\": [], \"chatUpstream\": [],\n \"responsesUpstream\": [{\"accountUid\":\"acct_custom\",\"channelUid\":\"ch_custom\",\"name\":\"custom-endpoint\",\"serviceType\":\"responses\",\"baseUrl\":\"https://example.com\",\"apiKeys\":[\"sk-custom-secret\"],\"apiKeyConfigs\":[{\"key\":\"sk-custom-secret\",\"credentialUid\":\"cred_custom\",\"baseUrl\":\"https://example.com\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tstore, err := NewProfileStoreWithDB(newTestDB(t))\n\tif err != nil {\n\t\tt.Fatalf(\"NewProfileStoreWithDB 失败: %v\", err)\n\t}\n\tdiscoveredAt := time.Date(2026, 7, 25, 5, 11, 29, 0, time.UTC)\n\tprofile := &KeyEndpointProfile{\n\t\tEndpointUID: \"ep_custom\", AccountUID: \"acct_custom\", ChannelUID: \"ch_custom\",\n\t\tCredentialUID: \"cred_custom\", KeyMask: \"sk-c***ret\", ServiceType: \"responses\",\n\t\tAvailableModels: []string{\"gpt-5.6-sol\"}, ModelsDiscoveredAt: &discoveredAt,\n\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI,\n\t\tProtocolModels: map[string][]string{\n\t\t\t\"messages\": {\"gpt-5.6-sol\"},\n\t\t\t\"chat\": {\"gpt-5.6-sol\"},\n\t\t\t\"responses\": {\"gpt-5.6-sol\"},\n\t\t},\n\t\tProtocolDiscoveredAt: map[string]time.Time{\n\t\t\t\"messages\": discoveredAt, \"chat\": discoveredAt, \"responses\": discoveredAt,\n\t\t},\n\t\tProtocolDiscoverySource: map[string]string{\n\t\t\t\"messages\": \"protocol_probe\", \"chat\": \"protocol_probe\", \"responses\": \"models_api\",\n\t\t},\n\t\tUpdatedAt: discoveredAt,\n\t}\n\tif err := store.Upsert(profile); err != nil {\n\t\tt.Fatalf(\"ProfileStore.Upsert 失败: %v\", err)\n\t}\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: cfgManager,\n\t\tRunner: NewAutoDiscoveryRunner(store, nil),\n\t})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tif strings.Contains(w.Body.String(), \"sk-custom-secret\") {\n\t\tt.Fatalf(\"账号列表泄露自定义渠道明文 Key: %s\", w.Body.String())\n\t}\n\n\tvar response struct {\n\t\tAccounts []managedAccountView `json:\"accounts\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatalf(\"解析响应失败: %v\", err)\n\t}\n\tif len(response.Accounts) != 1 {\n\t\tt.Fatalf(\"Accounts=%+v, want 1\", response.Accounts)\n\t}\n\taccount := response.Accounts[0]\n\tif account.AccountUID != \"acct_custom\" || len(account.Credentials) != 1 || len(account.Channels) != 1 {\n\t\tt.Fatalf(\"自定义自动托管账号聚合错误: %+v\", account)\n\t}\n\tprotocols := make([]string, 0, len(account.Channels[0].ProtocolAvailability))\n\tfor _, availability := range account.Channels[0].ProtocolAvailability {\n\t\tprotocols = append(protocols, availability.Protocol)\n\t}\n\tif got, want := strings.Join(protocols, \",\"), \"messages,chat,responses\"; got != want {\n\t\tt.Fatalf(\"ProtocolAvailability=%q, want %q\", got, want)\n\t}\n}\n\nfunc TestManagedChannelModelAvailabilityIncludesEmptyAutoDiscoveryResult(t *testing.T) {\n\tupdatedAt := time.Date(2026, 7, 17, 11, 2, 0, 0, time.UTC)\n\tmodels, bindings, latest, known := managedChannelModelAvailability([]*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_empty\", CredentialUID: \"cred_empty\", KeyMask: \"ark-e***001\",\n\t\t\tSource: \"auto_discovery\", AvailableModels: []string{}, UpdatedAt: updatedAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_models\", CredentialUID: \"cred_models\", KeyMask: \"ark-m***002\",\n\t\t\tAvailableModels: []string{\"model-a\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_unknown\", CredentialUID: \"cred_unknown\", KeyMask: \"ark-u***003\",\n\t\t\tSource: \"l1_passive\", UpdatedAt: updatedAt.Add(2 * time.Minute),\n\t\t},\n\t})\n\n\tif !known {\n\t\tt.Fatal(\"自动发现的空模型清单仍应标记为已知\")\n\t}\n\tif got, want := strings.Join(models, \",\"), \"model-a\"; got != want {\n\t\tt.Fatalf(\"models=%q, want %q\", got, want)\n\t}\n\tif len(bindings) != 2 {\n\t\tt.Fatalf(\"bindings=%+v, want only two known inventories\", bindings)\n\t}\n\tif got := bindings[0]; got.KeyMask != \"ark-e***001\" || len(got.Models) != 0 {\n\t\tt.Fatalf(\"empty discovery binding=%+v, want empty ark-e binding\", got)\n\t}\n\tif latest == nil || !latest.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"latest=%v, want discovered inventory update time\", latest)\n\t}\n}\n\nfunc TestManagedChannelModelAvailabilityIncludesDiscoveryMetadata(t *testing.T) {\n\tfirst := time.Date(2026, 7, 22, 0, 42, 12, 0, time.UTC)\n\tsecond := first.Add(time.Minute)\n\tinventory := managedChannelModelAvailabilityDetails([]*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep-a\",\n\t\t\tCredentialUID: \"cred-a\",\n\t\t\tKeyMask: \"ark-a***001\",\n\t\t\tAvailableModels: []string{\"glm-5.2\"},\n\t\t\tUpdatedAt: first,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceControlPlane,\n\t\t\tModelDiscoveryMessage: \"火山管控面 Coding Plan 模型清单\",\n\t\t\tModelsDiscoveredAt: &first,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep-b\",\n\t\t\tCredentialUID: \"cred-b\",\n\t\t\tKeyMask: \"ark-b***002\",\n\t\t\tAvailableModels: []string{\"deepseek-v4-pro\"},\n\t\t\tUpdatedAt: second,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceBuiltinFallback,\n\t\t\tModelDiscoveryMessage: \"管控面失败,已回退内置模型清单\",\n\t\t\tModelsDiscoveredAt: &second,\n\t\t},\n\t})\n\n\tif !inventory.known || inventory.source != \"mixed\" {\n\t\tt.Fatalf(\"inventory source=%q known=%v\", inventory.source, inventory.known)\n\t}\n\tif !inventory.latestDiscoveredAt.Equal(second) || inventory.message != \"管控面失败,已回退内置模型清单\" {\n\t\tt.Fatalf(\"inventory discovery metadata=%+v\", inventory)\n\t}\n\tif len(inventory.bindings) != 2 || inventory.bindings[1].ModelsDiscoveredAt == nil {\n\t\tt.Fatalf(\"binding discovery metadata=%+v\", inventory.bindings)\n\t}\n}\n\nfunc TestManagedProtocolAvailabilityViewsAggregatesByProtocolAndCredential(t *testing.T) {\n\tdiscoveredAt := time.Date(2026, 7, 25, 10, 0, 0, 0, time.UTC)\n\tprofiles := []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep-a\", CredentialUID: \"cred-a\", KeyMask: \"sk-a***001\", ServiceType: \"responses\",\n\t\t\tUpdatedAt: discoveredAt, ProtocolModels: map[string][]string{\n\t\t\t\t\"responses\": {\"shared\", \"responses-only\"},\n\t\t\t\t\"chat\": {\"shared\"},\n\t\t\t},\n\t\t\tProtocolDiscoveredAt: map[string]time.Time{\"responses\": discoveredAt, \"chat\": discoveredAt},\n\t\t\tProtocolDiscoverySource: map[string]string{\"responses\": \"models_api\", \"chat\": \"protocol_probe\"},\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep-b\", CredentialUID: \"cred-b\", KeyMask: \"sk-b***002\", ServiceType: \"responses\",\n\t\t\tUpdatedAt: discoveredAt, ProtocolModels: map[string][]string{\n\t\t\t\t\"responses\": {\"shared\"},\n\t\t\t\t\"messages\": {\"shared\", \"messages-only\"},\n\t\t\t},\n\t\t\tProtocolDiscoveredAt: map[string]time.Time{\"responses\": discoveredAt, \"messages\": discoveredAt},\n\t\t\tProtocolDiscoverySource: map[string]string{\"responses\": \"models_api\", \"messages\": \"protocol_probe\"},\n\t\t},\n\t}\n\n\tviews := managedProtocolAvailabilityViews(profiles)\n\tif len(views) != 3 {\n\t\tt.Fatalf(\"views=%+v\", views)\n\t}\n\tif views[0].Protocol != \"messages\" || strings.Join(views[0].DiscoveredModels, \",\") != \"messages-only,shared\" {\n\t\tt.Fatalf(\"messages view=%+v\", views[0])\n\t}\n\tif views[2].Protocol != \"responses\" || len(views[2].ModelBindings) != 2 {\n\t\tt.Fatalf(\"responses view=%+v\", views[2])\n\t}\n}\n\nfunc TestPatchAccountCredentialsRemovesByUID(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"sk-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"sk-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"baseUrls\":[\"https://api.xiaomimimo.com/anthropic\"],\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPatch, \"/api/accounts/acct_test/credentials\", bytes.NewBufferString(`{\"removeCredentialUids\":[\"cred_b\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PATCH credentials status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 1 || len(channels[0].Upstream.APIKeys) != 1 || channels[0].Upstream.APIKeys[0] != \"sk-a\" {\n\t\tt.Fatalf(\"按 credentialUid 删除失败: %+v\", channels)\n\t}\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestInferAutoAddProviderID(t *testing.T) {\n\tzhipuKey := \"0123456789abcdef0123456789abcdef.ABCDEFGHIJKLMNO1\"\n\ttests := []struct {\n\t\tname string\n\t\tbaseURLs []string\n\t\tapiKeys []string\n\t\twant string\n\t}{\n\t\t{name: \"官方 Claude URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"官方 OpenAI URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4/\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"两个官方协议 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\", \"https://open.bigmodel.cn/api/paas/v4\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"仅智谱 Key\", apiKeys: []string{zhipuKey}, want: \"glm\"},\n\t\t{name: \"第三方 URL 不按 Key 提升\", baseURLs: []string{\"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"混合官方与第三方 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4\", \"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"共享 sk Key 无法推断\", apiKeys: []string{\"sk-abcdefghijklmnopqrstuvwxyz123456\"}},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := inferAutoAddProviderID(tt.baseURLs, tt.apiKeys); got != tt.want {\n\t\t\t\tt.Fatalf(\"inferAutoAddProviderID() = %q, want %q\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestApplyProviderUpstreamDefaults(t *testing.T) {\n\t// shouldPassbackReasoningContentByDefault 按 ProviderID+ServiceType 静态推导(GLM 已知\n\t// 协议事实),ApplyProviderUpstreamDefaults 不再写字段,测试必须设置 ProviderID 才能命中。\n\tglmChat := config.UpstreamConfig{ProviderID: \"glm\", ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmChat)\n\tif glmChat.ReasoningParamStyle != \"reasoning_effort\" || !glmChat.IsPassbackReasoningContentEnabled() {\n\t\tt.Fatalf(\"GLM OpenAI 默认兼容参数未补齐: %+v\", glmChat)\n\t}\n\n\tglmClaude := config.UpstreamConfig{ProviderID: \"glm\", ServiceType: \"claude\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmClaude)\n\tif glmClaude.ReasoningParamStyle != \"\" || glmClaude.IsPassbackReasoningContentEnabled() {\n\t\tt.Fatalf(\"GLM Claude 原生 route 不应注入 OpenAI 参数: %+v\", glmClaude)\n\t}\n\n\tcustom := config.UpstreamConfig{ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"\", &custom)\n\tif custom.ReasoningParamStyle != \"\" || custom.IsPassbackReasoningContentEnabled() {\n\t\tt.Fatalf(\"自定义渠道不应应用 GLM 默认值: %+v\", custom)\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCustomAutoAddResponseIncludesActualRoute(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\tdiscoverer := NewRateLimitDiscoverer(RateLimitDiscovererConfig{PassiveAimdEnabled: true, QuietLogs: true})\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, RateLimitDiscoverer: discoverer})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"fastaitoken-com-test\",\"baseUrls\":[\"https://example.com\"],\"apiKeys\":[\"sk-test\"],\"rateLimitHint\":{\"initialRpm\":30,\"effectiveRpm\":15,\"rateLimited\":true,\"rateLimitedCount\":1}}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 {\n\t\tt.Fatalf(\"channels=%+v, want one actual route\", response.Channels)\n\t}\n\troute := response.Channels[0]\n\tif route.ChannelKind != \"responses\" || route.ServiceType != \"responses\" || route.ChannelUID != response.ChannelUID {\n\t\tt.Fatalf(\"route=%+v response=%+v\", route, response)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.ResponsesUpstream) != 1 || len(cfg.Upstream) != 0 {\n\t\tt.Fatalf(\"custom route persisted in wrong channel: responses=%d messages=%d\", len(cfg.ResponsesUpstream), len(cfg.Upstream))\n\t}\n\tcreated := cfg.ResponsesUpstream[0]\n\tbaseURL := created.GetEffectiveBaseURL()\n\tendpointUID := GenerateEndpointUID(created.ChannelUID, baseURL, KeyHashFromAPIKey(\"sk-test\"))\n\tif suggestion := discoverer.SuggestedLimit(endpointUID); suggestion.RPM != 15 || suggestion.Confidence != 0.7 {\n\t\tt.Fatalf(\"rate limit suggestion=%+v, want rpm=15 confidence=0.7\", suggestion)\n\t}\n\n\tstore := newTestProfileStore(t)\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\tseedAutoAddRateLimitHint(\n\t\t&AutoManagedDeps{CfgManager: manager, Runner: runner, RateLimitDiscoverer: discoverer},\n\t\tmanager.GetAccountChannels(response.AccountUID),\n\t\t&AutoAddRateLimitHint{InitialRPM: 30, EffectiveRPM: 15, RateLimited: true, RateLimitedCount: 1},\n\t\t\"sk-test\",\n\t\t\"https://example.com\",\n\t)\n\tprofile := store.Get(endpointUID)\n\tif profile == nil || profile.DiscoveredRPM != 15 || profile.RateLimitConfidence != 0.7 || profile.RateLimitSource != string(RateLimitSourcePassiveAIMD) {\n\t\tt.Fatalf(\"rate limit profile=%+v\", profile)\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyToEquivalentExistingBaseURL(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [],\n \"chatUpstream\": [{\"channelUid\":\"ch_existing\",\"name\":\"localhost-old-name\",\"serviceType\":\"openai\",\"baseUrl\":\"http://localhost:8990/v1\",\"apiKeys\":[\"sk-old\"],\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"localhost-8990\",\"baseUrls\":[\"http://localhost:8990/\"],\"apiKeys\":[\"sk-new\"]}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 || response.Channels[0].Name != \"localhost-old-name\" {\n\t\tt.Fatalf(\"应返回已有渠道: %+v\", response.Channels)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 0 || len(cfg.ChatUpstream) != 1 {\n\t\tt.Fatalf(\"重复请求不应创建新渠道: messages=%d chat=%d\", len(cfg.Upstream), len(cfg.ChatUpstream))\n\t}\n\tif got := strings.Join(cfg.ChatUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"已有渠道密钥=%q, want sk-old,sk-new\", got)\n\t}\n}\n\nfunc TestFindExistingAutoAddChannelsPreservesHashSemantics(t *testing.T) {\n\tcfg := config.Config{\n\t\tChatUpstream: []config.UpstreamConfig{{\n\t\t\tChannelUID: \"ch-hash\", ServiceType: \"openai\", BaseURL: \"https://example.com#\",\n\t\t}},\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com\"}); len(matches) != 0 {\n\t\tt.Fatalf(\"普通 Base URL 不应匹配禁止版本前缀的地址: %+v\", matches)\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com/#\"}); len(matches) != 1 {\n\t\tt.Fatalf(\"等效 # 地址匹配数量=%d, want 1\", len(matches))\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyAndMissingRouteToManagedAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [{\"accountUid\":\"acct_existing\",\"channelUid\":\"ch_existing\",\"name\":\"localhost-8990\",\"serviceType\":\"claude\",\"baseUrl\":\"http://localhost:8990\",\"apiKeys\":[\"sk-old\"],\"autoManaged\":true,\"status\":\"active\"}],\n \"chatUpstream\": [], \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"localhost-8990\",\n \"baseUrls\":[\"http://localhost:8990/v1\"],\n \"apiKeys\":[\"sk-new\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"claude-test\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"gpt-test\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d\", len(cfg.Upstream), len(cfg.ResponsesUpstream))\n\t}\n\tif cfg.Upstream[0].AccountUID != \"acct_existing\" || cfg.ResponsesUpstream[0].AccountUID != \"acct_existing\" {\n\t\tt.Fatalf(\"应复用已有账号: messages=%q responses=%q\", cfg.Upstream[0].AccountUID, cfg.ResponsesUpstream[0].AccountUID)\n\t}\n\tif got := strings.Join(cfg.Upstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"messages 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif got := strings.Join(cfg.ResponsesUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"responses 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif cfg.Upstream[0].Name != \"localhost-8990-claude\" || cfg.ResponsesUpstream[0].Name != \"localhost-8990-codex\" {\n\t\tt.Fatalf(\"多协议自动命名错误: messages=%q responses=%q\", cfg.Upstream[0].Name, cfg.ResponsesUpstream[0].Name)\n\t}\n}\n\nfunc TestCustomAutoAddCreatesAllDetectedRoutesWithProtocolModels(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"fastaitoken-com-test\",\n \"baseUrls\":[\"https://example.com/keys\"],\n \"apiKeys\":[\"sk-test\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"shared\",\"messages-only\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"shared\",\"responses-only\"]},\n {\"channelKind\":\"chat\",\"supportedModels\":[\"shared\",\"chat-only\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 3 {\n\t\tt.Fatalf(\"channels=%+v, want three detected routes\", response.Channels)\n\t}\n\tfor _, route := range response.Channels {\n\t\tif route.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route account=%q, want %q\", route.AccountUID, response.AccountUID)\n\t\t}\n\t}\n\tif response.Channels[1].ChannelKind != \"responses\" || response.ChannelUID != response.Channels[1].ChannelUID {\n\t\tt.Fatalf(\"请求协议应作为主路由: response=%+v\", response)\n\t}\n\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 || len(cfg.ChatUpstream) != 1 || len(cfg.GeminiUpstream) != 0 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d chat=%d gemini=%d\",\n\t\t\tlen(cfg.Upstream), len(cfg.ResponsesUpstream), len(cfg.ChatUpstream), len(cfg.GeminiUpstream))\n\t}\n\tchecks := []struct {\n\t\tchannel config.UpstreamConfig\n\t\tname string\n\t\tmodels []string\n\t}{\n\t\t{cfg.Upstream[0], \"fastaitoken-com-test-claude\", []string{\"shared\", \"messages-only\"}},\n\t\t{cfg.ResponsesUpstream[0], \"fastaitoken-com-test-codex\", []string{\"shared\", \"responses-only\"}},\n\t\t{cfg.ChatUpstream[0], \"fastaitoken-com-test-chat\", []string{\"shared\", \"chat-only\"}},\n\t}\n\tfor _, check := range checks {\n\t\tif check.channel.Name != check.name || check.channel.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route identity=%+v, want name=%q account=%q\", check.channel, check.name, response.AccountUID)\n\t\t}\n\t\tif strings.Join(check.channel.SupportedModels, \",\") != strings.Join(check.models, \",\") {\n\t\t\tt.Fatalf(\"route %s models=%v, want %v\", check.name, check.channel.SupportedModels, check.models)\n\t\t}\n\t}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesRenamesAndSyncsCredentials(t *testing.T) {\n\tchannels := []config.AccountChannel{\n\t\t{Kind: \"messages\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-messages\", Name: \"old-claude\",\n\t\t\tServiceType: \"claude\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t\t{Kind: \"responses\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-responses\", Name: \"old-codex\",\n\t\t\tServiceType: \"responses\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t}\n\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\tName: \"renamed\", APIKeys: []string{\"sk-old\", \"sk-new\"},\n\t}, channels, len(channels))\n\tif err != nil || status != http.StatusOK {\n\t\tt.Fatalf(\"status=%d err=%v\", status, err)\n\t}\n\tif len(updates) != 2 || updates[0].Name != \"renamed-claude\" || updates[1].Name != \"renamed-codex\" {\n\t\tt.Fatalf(\"route names=%+v\", updates)\n\t}\n\tfor _, update := range updates {\n\t\tif len(update.APIKeyConfig) != 2 || update.APIKeyConfig[1].BaseURL != \"https://example.com\" {\n\t\t\tt.Fatalf(\"route credentials=%+v\", update.APIKeyConfig)\n\t\t}\n\t\tif update.APIKeyConfig[1].CredentialUID == \"\" {\n\t\t\tt.Fatalf(\"new credential missing stable uid: %+v\", update.APIKeyConfig[1])\n\t\t}\n\t}\n}\n\n// newCustomManagedChannel 构造一条自定义自动托管渠道,用于地址池编辑链路的表驱动断言。\nfunc newCustomManagedChannel(kind, serviceType string, baseURLs []string, keyConfigs []config.APIKeyConfig) config.AccountChannel {\n\tapiKeys := make([]string, 0, len(keyConfigs))\n\tfor _, keyConfig := range keyConfigs {\n\t\tapiKeys = append(apiKeys, keyConfig.Key)\n\t}\n\tprimary := \"\"\n\tif len(baseURLs) > 0 {\n\t\tprimary = baseURLs[0]\n\t}\n\treturn config.AccountChannel{Kind: kind, Upstream: config.UpstreamConfig{\n\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-\" + kind, Name: \"custom\" + accountRouteSuffix(kind),\n\t\tServiceType: serviceType, AutoManaged: true, BaseURL: primary,\n\t\tBaseURLs: append([]string(nil), baseURLs...), APIKeys: apiKeys,\n\t\tAPIKeyConfigs: append([]config.APIKeyConfig(nil), keyConfigs...),\n\t}}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesBaseURLPool(t *testing.T) {\n\tconst (\n\t\turlA = \"https://a.example.com\"\n\t\turlB = \"https://b.example.com\"\n\t\turlC = \"https://c.example.com\"\n\t)\n\ttests := []struct {\n\t\tname string\n\t\t// existingURLs / existingKeys 描述渠道现状,requestURLs 为请求里的 baseUrls(nil 表示未提供)\n\t\texistingURLs []string\n\t\texistingKeys []config.APIKeyConfig\n\t\tapiKeys []string\n\t\trequestURLs []string\n\t\twantStatus int\n\t\twantErr bool\n\t\twantBaseURLs []string\n\t\t// wantBindings 与 apiKeys 顺序一致,空串表示该 Key 未绑定端点\n\t\twantBindings []string\n\t}{\n\t\t{\n\t\t\tname: \"单地址扩为两地址\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA, urlB},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlB},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"两地址删除其中一个且保留有效绑定\",\n\t\t\texistingURLs: []string{urlA, urlB},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"地址顺序变化改变首地址\",\n\t\t\texistingURLs: []string{urlA, urlB},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlB, urlA},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlB, urlA},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"尾部斜杠与默认版本前缀等价地址去重\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA, urlA + \"/\", urlA + \"/v1\", \" \" + urlB + \"/ \", urlB},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlB},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"全部非法地址被拒绝\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{\"\", \" \", \"/\"},\n\t\t\twantStatus: http.StatusBadRequest,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"未提供地址时沿用渠道现有地址\",\n\t\t\texistingURLs: []string{urlA, urlB},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlB}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: nil,\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlB},\n\t\t\twantBindings: []string{urlB},\n\t\t},\n\t\t{\n\t\t\tname: \"绑定指向已删除地址时迁移到新首地址\",\n\t\t\texistingURLs: []string{urlA, urlB},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlB}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA, urlC},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlC},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"原本未绑定的凭证保持未绑定\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\"}, {Key: \"sk-2\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\", \"sk-2\", \"sk-new\"},\n\t\t\trequestURLs: []string{urlB, urlA},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlB, urlA},\n\t\t\t// sk-1 未绑定保持为空;sk-2 绑定仍在池内保持不变;新增 sk-new 绑定首地址\n\t\t\twantBindings: []string{\"\", urlA, urlB},\n\t\t},\n\t\t{\n\t\t\tname: \"非规范绑定被折叠为 canonical 形式\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA + \"/v1/\"}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA, urlB},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlB},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tchannels := []config.AccountChannel{\n\t\t\t\tnewCustomManagedChannel(\"messages\", \"claude\", tt.existingURLs, tt.existingKeys),\n\t\t\t}\n\t\t\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\t\t\tName: \"custom\", APIKeys: tt.apiKeys, BaseURLs: tt.requestURLs,\n\t\t\t}, channels, len(channels))\n\t\t\tif status != tt.wantStatus || (err != nil) != tt.wantErr {\n\t\t\t\tt.Fatalf(\"status=%d err=%v, want status=%d err=%v\", status, err, tt.wantStatus, tt.wantErr)\n\t\t\t}\n\t\t\tif tt.wantErr {\n\t\t\t\tif len(updates) != 0 {\n\t\t\t\t\tt.Fatalf(\"非法输入不应产生渠道更新: %+v\", updates)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(updates) != 1 {\n\t\t\t\tt.Fatalf(\"updates=%+v, want 1\", updates)\n\t\t\t}\n\t\t\tupdate := updates[0]\n\t\t\tif strings.Join(update.BaseURLs, \",\") != strings.Join(tt.wantBaseURLs, \",\") {\n\t\t\t\tt.Fatalf(\"BaseURLs=%v, want %v\", update.BaseURLs, tt.wantBaseURLs)\n\t\t\t}\n\t\t\tif len(update.APIKeyConfig) != len(tt.wantBindings) {\n\t\t\t\tt.Fatalf(\"APIKeyConfig=%+v, want %d 条\", update.APIKeyConfig, len(tt.wantBindings))\n\t\t\t}\n\t\t\tfor i, keyConfig := range update.APIKeyConfig {\n\t\t\t\tif keyConfig.Key != tt.apiKeys[i] {\n\t\t\t\t\tt.Fatalf(\"APIKeyConfig[%d].Key=%q, want %q\", i, keyConfig.Key, tt.apiKeys[i])\n\t\t\t\t}\n\t\t\t\tif keyConfig.BaseURL != tt.wantBindings[i] {\n\t\t\t\t\tt.Fatalf(\"APIKeyConfig[%d](%s).BaseURL=%q, want %q\", i, keyConfig.Key, keyConfig.BaseURL, tt.wantBindings[i])\n\t\t\t\t}\n\t\t\t\tif keyConfig.CredentialUID == \"\" {\n\t\t\t\t\tt.Fatalf(\"APIKeyConfig[%d] 缺少稳定 credentialUid: %+v\", i, keyConfig)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesSyncsBaseURLPoolAcrossProtocols(t *testing.T) {\n\tconst (\n\t\turlA = \"https://a.example.com\"\n\t\turlB = \"https://b.example.com\"\n\t)\n\texistingKeys := []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}}\n\tchannels := []config.AccountChannel{\n\t\tnewCustomManagedChannel(\"messages\", \"claude\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"chat\", \"openai\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"responses\", \"responses\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"gemini\", \"gemini\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"images\", \"openai\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"vectors\", \"openai\", []string{urlA}, existingKeys),\n\t}\n\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\tName: \"custom\", APIKeys: []string{\"sk-1\"}, BaseURLs: []string{urlB + \"/\", urlA},\n\t}, channels, len(channels))\n\tif err != nil || status != http.StatusOK {\n\t\tt.Fatalf(\"status=%d err=%v\", status, err)\n\t}\n\tif len(updates) != len(channels) {\n\t\tt.Fatalf(\"updates=%d, want %d\", len(updates), len(channels))\n\t}\n\twantPool := urlB + \",\" + urlA\n\tfor i, update := range updates {\n\t\tif got := strings.Join(update.BaseURLs, \",\"); got != wantPool {\n\t\t\tt.Fatalf(\"%s 渠道地址池=%q, want %q\", channels[i].Kind, got, wantPool)\n\t\t}\n\t\tif len(update.APIKeyConfig) != 1 || update.APIKeyConfig[0].BaseURL != urlA {\n\t\t\tt.Fatalf(\"%s 渠道凭证绑定=%+v, want %s\", channels[i].Kind, update.APIKeyConfig, urlA)\n\t\t}\n\t}\n}\n\nfunc TestUpdateAccountIgnoresBaseURLsForProviderManagedAccount(t *testing.T) {\n\tconst templateBaseURL = \"https://api.xiaomimimo.com/anthropic\"\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_provider\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_provider\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"` + templateBaseURL + `\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"` + templateBaseURL + `\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\tbody := `{\"name\":\"mimo-main\",\"apiKeys\":[\"sk-existing\"],\"baseUrls\":[\"https://manual-pool.example.com\"]}`\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_provider\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PUT /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_provider\")\n\tif len(channels) == 0 {\n\t\tt.Fatalf(\"provider 托管账号渠道丢失\")\n\t}\n\tfor _, accountChannel := range channels {\n\t\tchannel := accountChannel.Upstream\n\t\tif channel.BaseURL != templateBaseURL {\n\t\t\tt.Fatalf(\"%s 渠道主地址被手工地址覆盖: %q\", accountChannel.Kind, channel.BaseURL)\n\t\t}\n\t\tfor _, baseURL := range channel.GetAllBaseURLs() {\n\t\t\tif strings.Contains(baseURL, \"manual-pool\") {\n\t\t\t\tt.Fatalf(\"%s 渠道地址池混入手工地址: %v\", accountChannel.Kind, channel.GetAllBaseURLs())\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestProviderRouteNameAndPrimaryResult(t *testing.T) {\n\tbase := \"mimo-test\"\n\ttests := []struct {\n\t\troute config.ProviderRoute\n\t\twant string\n\t}{\n\t\t{route: config.ProviderRoute{ChannelKind: \"messages\"}, want: \"mimo-test-claude\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"chat\"}, want: \"mimo-test-chat\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"responses\"}, want: \"mimo-test-codex\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"gemini\"}, want: \"mimo-test-gemini\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := providerRouteName(base, tt.route, true); got != tt.want {\n\t\t\tt.Fatalf(\"providerRouteName(%s)=%q, want %q\", tt.route.ChannelKind, got, tt.want)\n\t\t}\n\t}\n\tif got := providerRouteName(base, config.ProviderRoute{ChannelKind: \"messages\"}, false); got != base {\n\t\tt.Fatalf(\"single route name=%q, want %q\", got, base)\n\t}\n\n\tresults := []AutoAddChannelResult{\n\t\t{ChannelKind: \"messages\", ChannelUID: \"ch_messages\", Index: 1},\n\t\t{ChannelKind: \"chat\", ChannelUID: \"ch_chat\", Index: 2},\n\t\t{ChannelKind: \"responses\", ChannelUID: \"ch_responses\", Index: 3},\n\t}\n\tprimary := primaryAutoAddResult(results, \"chat\")\n\tif primary.ChannelUID != \"ch_chat\" || primary.Index != 2 {\n\t\tt.Fatalf(\"primary=%+v, want chat result\", primary)\n\t}\n}\n\nfunc TestManagedAccountUIDForProviderUsesExistingAccount(t *testing.T) {\n\tcfg := config.Config{ManagedAccounts: []config.ManagedAccountConfig{\n\t\t{AccountUID: \"acct-old\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-new\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-deepseek\", ProviderID: \"deepseek\"},\n\t}}\n\tif got := managedAccountUIDForProvider(cfg, \"mimo\"); got != \"acct-new\" {\n\t\tt.Fatalf(\"mimo existing account = %q, want acct-new\", got)\n\t}\n\tif got := managedAccountUIDForProvider(cfg, \"unknown\"); got != \"\" {\n\t\tt.Fatalf(\"unknown provider 不应命中账号: %q\", got)\n\t}\n}\n\nfunc TestListManagedAccountsDoesNotExposeVolcengineSecret(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-secret\",\"volcengineAccessKey\":{\"accessKeyId\":\"AKIDEXAMPLE\",\"secretAccessKey\":\"SECRET_MUST_NOT_LEAK\",\"plan\":\"agent_plan\",\"planTier\":\"Large\",\"planStatus\":\"Running\"}}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"SECRET_MUST_NOT_LEAK\") || strings.Contains(body, \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", body)\n\t}\n\tfor _, expected := range []string{`\"hasVolcengineAccessKey\":true`, `\"volcenginePlan\":\"agent_plan\"`, `\"volcenginePlanTier\":\"Large\"`} {\n\t\tif !strings.Contains(body, expected) {\n\t\t\tt.Fatalf(\"响应缺少 %s: %s\", expected, body)\n\t\t}\n\t}\n}\n\nfunc TestKimiConsoleTokenLifecycleDoesNotExposeToken(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_kimi\",\"providerId\":\"kimi\",\"name\":\"kimi\",\"credentials\":[{\"credentialUid\":\"cred_kimi\",\"apiKey\":\"sk-kimi-secret\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_kimi\",\"channelUid\":\"ch_kimi\",\"providerId\":\"kimi\",\"name\":\"kimi\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.kimi.com/coding\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_kimi\",\"baseUrl\":\"https://api.kimi.com/coding\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tkimiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Authorization\") != \"Bearer web-session-secret\" {\n\t\t\tt.Fatalf(\"Kimi 请求未使用保存的会话令牌\")\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase kimiUsagesPath:\n\t\t\t_, _ = w.Write([]byte(`{\"usages\":[{\"scope\":\"FEATURE_CODING\",\"detail\":{\"limit\":\"100\",\"remaining\":\"80\",\"resetTime\":\"2026-07-27T16:13:43Z\"},\"limits\":[{\"window\":{\"duration\":300,\"timeUnit\":\"TIME_UNIT_MINUTE\"},\"detail\":{\"limit\":\"100\",\"remaining\":\"90\",\"resetTime\":\"2026-07-21T02:13:43Z\"}}]}],\"totalQuota\":{\"limit\":\"100\",\"remaining\":\"80\"}}`))\n\t\tcase kimiSubscriptionStatsPath:\n\t\t\t_, _ = w.Write([]byte(`{\"ratelimitCode5h\":{\"ratio\":0.1,\"enabled\":true},\"ratelimitCode7d\":{\"ratio\":0.2,\"enabled\":true},\"subscriptionBalance\":{\"feature\":\"FEATURE_OMNI\",\"type\":\"SUBSCRIPTION\",\"unit\":\"UNIT_CREDIT\",\"amountUsedRatio\":0.2,\"kimiCodeUsedRatio\":0.2,\"expireTime\":\"2026-08-20T16:14:07Z\"}}`))\n\t\tdefault:\n\t\t\thttp.NotFound(w, r)\n\t\t}\n\t}))\n\tdefer kimiServer.Close()\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: cfgManager,\n\t\tKimiConsoleClient: &KimiConsoleClient{\n\t\t\tHTTPClient: kimiServer.Client(), BaseURL: kimiServer.URL,\n\t\t},\n\t})\n\tput := httptest.NewRequest(\n\t\thttp.MethodPut,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token\",\n\t\tbytes.NewBufferString(`{\"accessToken\":\"Bearer web-session-secret\"}`),\n\t)\n\tput.Header.Set(\"Content-Type\", \"application/json\")\n\tputRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(putRecorder, put)\n\tif putRecorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"绑定 Kimi 令牌失败: status=%d body=%s\", putRecorder.Code, putRecorder.Body.String())\n\t}\n\tif strings.Contains(putRecorder.Body.String(), \"web-session-secret\") || strings.Contains(putRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"绑定响应泄露 Kimi 令牌: %s\", putRecorder.Body.String())\n\t}\n\tcredential, ok := cfgManager.GetManagedAccountCredential(\"acct_kimi\", \"cred_kimi\")\n\tif !ok || credential.KimiConsole == nil || credential.KimiConsole.AccessToken != \"web-session-secret\" {\n\t\tt.Fatalf(\"Kimi 令牌未持久化: %+v\", credential)\n\t}\n\tpersisted, err := os.ReadFile(configPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !strings.Contains(string(persisted), `\"accessToken\": \"web-session-secret\"`) {\n\t\tt.Fatalf(\"配置文件未保存 Kimi 令牌\")\n\t}\n\n\tlistRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(listRecorder, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif listRecorder.Code != http.StatusOK || strings.Contains(listRecorder.Body.String(), \"web-session-secret\") || strings.Contains(listRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"账号列表响应异常或泄露令牌: status=%d body=%s\", listRecorder.Code, listRecorder.Body.String())\n\t}\n\tif !strings.Contains(listRecorder.Body.String(), `\"hasKimiConsoleToken\":true`) || !strings.Contains(listRecorder.Body.String(), `\"kimiCodeUsage\"`) {\n\t\tt.Fatalf(\"账号列表缺少 Kimi 用量快照: %s\", listRecorder.Body.String())\n\t}\n\n\trefreshRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(refreshRecorder, httptest.NewRequest(\n\t\thttp.MethodPost,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token/refresh\",\n\t\tnil,\n\t))\n\tif refreshRecorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"刷新 Kimi 余量失败: status=%d body=%s\", refreshRecorder.Code, refreshRecorder.Body.String())\n\t}\n\tif strings.Contains(refreshRecorder.Body.String(), \"web-session-secret\") || strings.Contains(refreshRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"刷新响应泄露 Kimi 令牌: %s\", refreshRecorder.Body.String())\n\t}\n\n\tdeleteRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(deleteRecorder, httptest.NewRequest(\n\t\thttp.MethodDelete,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token\",\n\t\tnil,\n\t))\n\tif deleteRecorder.Code != http.StatusNoContent {\n\t\tt.Fatalf(\"解绑 Kimi 令牌失败: status=%d body=%s\", deleteRecorder.Code, deleteRecorder.Body.String())\n\t}\n\tcredential, _ = cfgManager.GetManagedAccountCredential(\"acct_kimi\", \"cred_kimi\")\n\tif credential.KimiConsole != nil {\n\t\tt.Fatalf(\"Kimi 令牌解绑后仍存在: %+v\", credential.KimiConsole)\n\t}\n\tpersisted, err = os.ReadFile(configPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(persisted), \"web-session-secret\") {\n\t\tt.Fatalf(\"解绑后配置文件仍包含 Kimi 令牌\")\n\t}\n}\n\nfunc TestSetVolcengineAccessKeyDetectsPlanBeforePersisting(t *testing.T) {\n\tcontrolPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Query().Get(\"Action\") != \"GetPersonalPlan\" {\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tvar body struct{ Plan string }\n\t\t_ = json.NewDecoder(r.Body).Decode(&body)\n\t\tif body.Plan == \"CodingPlan\" {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t_, _ = w.Write([]byte(`{\"ResponseMetadata\":{\"Error\":{\"Code\":\"ResourceNotFound.Plan\",\"Message\":\"not found\"}}}`))\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\"Result\":{\"PlanType\":\"Large\",\"Status\":\"Running\"}}`))\n\t}))\n\tdefer controlPlane.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-inference\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trunner := NewAutoDiscoveryRunner(nil, nil)\n\trunner.client = controlPlane.Client()\n\trunner.volcengineControlPlaneEndpoint = controlPlane.URL\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, Runner: runner})\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_volc/credentials/cred_volc/volcengine-access-key\", bytes.NewBufferString(`{\"accessKeyId\":\"AKID\",\"secretAccessKey\":\"SECRET\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"plan\":\"agent_plan\"`) {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_volc\", \"cred_volc\")\n\tif !ok || credential.VolcengineAccessKey == nil || credential.VolcengineAccessKey.SecretAccessKey != \"SECRET\" || credential.VolcengineAccessKey.Plan != \"agent_plan\" {\n\t\tt.Fatalf(\"凭证未正确持久化: %+v\", credential)\n\t}\n}\n\nfunc TestSetMiMoConsoleCookieRequiresConfirmationBeforeAdoptingKey(t *testing.T) {\n\tconst oldKey = \"tp-old-1234567890123456789012345678901234567890\"\n\tconst cookieKey = \"tp-new-1234567890123456789012345678901234567890\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != \"api-platform_serviceToken=session; userId=42\" {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase \"/api/v1/userProfile\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"userId\":\"42\"}}`))\n\t\tcase \"/api/v1/tokenPlan/detail\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"planCode\":\"max\",\"planName\":\"Max\",\"currentPeriodEnd\":\"2026-07-29 23:59:59\"}}`))\n\t\tcase \"/api/v1/tokenPlan/usage\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"monthUsage\":{\"percent\":0.25,\"items\":[{\"used\":25,\"limit\":100}]},\"usage\":{\"percent\":0.5,\"items\":[{\"used\":50,\"limit\":100}]}}}`))\n\t\tcase \"/api/v1/tokenPlan/apiKey/raw\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":\"` + cookieKey + `\"}`))\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}\n\t}))\n\tdefer console.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"credentials\":[{\"credentialUid\":\"cred_mimo\",\"apiKey\":\"` + oldKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_mimo\",\"channelUid\":\"ch_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_mimo\",\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\tdeps := &AutoManagedDeps{CfgManager: manager, MiMoConsoleClient: &MiMoConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL}}\n\trouter := setupAutoManagedRouter(deps)\n\tbody := `{\"cookie\":\"api-platform_serviceToken=session; userId=42\"}`\n\trequest := func(payload string) *httptest.ResponseRecorder {\n\t\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_mimo/credentials/cred_mimo/mimo-console-cookie\", bytes.NewBufferString(payload))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tw := httptest.NewRecorder()\n\t\trouter.ServeHTTP(w, req)\n\t\treturn w\n\t}\n\tw := request(body)\n\tif w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), `\"code\":\"mimo_cookie_key_mismatch\"`) {\n\t\tt.Fatalf(\"未要求确认: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ := manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != oldKey || credential.MiMoConsole != nil {\n\t\tt.Fatalf(\"确认前不应修改配置: %+v\", credential)\n\t}\n\tw = request(`{\"cookie\":\"api-platform_serviceToken=session; userId=42\",\"adoptCookieKey\":true}`)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"keyAdopted\":true`) {\n\t\tt.Fatalf(\"确认采用失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ = manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != cookieKey || credential.MiMoConsole == nil {\n\t\tt.Fatalf(\"确认后未采用 Cookie Key: %+v\", credential)\n\t}\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif strings.Contains(list.Body.String(), \"api-platform_serviceToken\") || strings.Contains(list.Body.String(), cookieKey) {\n\t\tt.Fatalf(\"账号列表泄漏 Cookie 或原始 Key: %s\", list.Body.String())\n\t}\n}\n\nfunc TestCompshareConsoleCookieBindingDoesNotExposeSecrets(t *testing.T) {\n\tconst currentKey = \"sk-cp-test-current\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != testCompshareCookie {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\n \"RetCode\": 0,\n \"UserPlans\": [{\n \"Code\": \"cppkg-test\", \"PlanCode\": \"cp-plan-test\", \"PlanName\": \"Pro\", \"DisplayName\": \"Pro 增强版\",\n \"LimitPer5h\": 3000, \"LimitPerWeek\": 7500, \"LimitPerMonth\": 19000,\n \"UsagePer5h\": 0, \"UsagePerWeek\": 100, \"UsagePerMonth\": 6496,\n \"ConcurrencyLimit\": 10, \"Status\": 1, \"ExpireAt\": 1785037981,\n \"Keys\": [{\"APIKey\": \"` + currentKey + `\"}]\n }]\n}`))\n\t}))\n\tdefer console.Close()\n\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_compshare\",\"providerId\":\"compshare\",\"name\":\"compshare\",\"credentials\":[{\"credentialUid\":\"cred_compshare\",\"apiKey\":\"` + currentKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_compshare\",\"channelUid\":\"ch_compshare\",\"providerId\":\"compshare\",\"name\":\"compshare\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://api.compshare.cn/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_compshare\",\"baseUrl\":\"https://api.compshare.cn/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: manager,\n\t\tCompshareConsoleClient: &CompshareConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL},\n\t})\n\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_compshare/credentials/cred_compshare/compshare-console-cookie\", bytes.NewBufferString(`{\"cookie\":\"`+testCompshareCookie+`\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"displayName\":\"Pro 增强版\"`) {\n\t\tt.Fatalf(\"绑定失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_compshare\", \"cred_compshare\")\n\tif !ok || credential.CompshareConsole == nil || credential.CompshareConsole.Cookie != testCompshareCookie {\n\t\tt.Fatalf(\"控制台套餐未持久化: %+v\", credential)\n\t}\n\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tresponse := list.Body.String()\n\tfor _, secret := range []string{\"U_JWT_TOKEN\", \"test-session\", \"test_user@console.compshare.cn\", \"org-test\", currentKey, \"cppkg-test\"} {\n\t\tif strings.Contains(response, secret) {\n\t\t\tt.Fatalf(\"账号列表泄漏敏感字段 %q: %s\", secret, response)\n\t\t}\n\t}\n\tfor _, expected := range []string{`\"hasCompshareConsoleCookie\":true`, `\"planCode\":\"cp-plan-test\"`, `\"monthlyUsage\":{\"used\":6496,\"limit\":19000`} {\n\t\tif !strings.Contains(response, expected) {\n\t\t\tt.Fatalf(\"账号列表缺少套餐字段 %s: %s\", expected, response)\n\t\t}\n\t}\n}\n\nfunc TestProviderAutoAddReusesExistingAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(`{\"providerId\":\"mimo\",\"apiKeys\":[\"sk-existing\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"追加已有 provider key status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 4 {\n\t\tt.Fatalf(\"旧账号应自动补齐模板中的协议渠道: %+v\", channels)\n\t}\n\tkinds := make(map[string]bool, len(channels))\n\tfor _, channel := range channels {\n\t\tkinds[channel.Kind] = true\n\t}\n\tfor _, kind := range []string{\"messages\", \"chat\", \"responses\", \"gemini\"} {\n\t\tif !kinds[kind] {\n\t\t\tt.Fatalf(\"旧账号缺少 %s route: %+v\", kind, channels)\n\t\t}\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.AccountUID != \"acct_test\" {\n\t\tt.Fatalf(\"响应未返回已有账号: body=%s err=%v\", w.Body.String(), err)\n\t}\n\tif len(response.Channels) != 4 {\n\t\tt.Fatalf(\"响应未返回补齐后的全部渠道: %+v\", response.Channels)\n\t}\n}\n\nfunc TestMissingProviderAccountRoutes(t *testing.T) {\n\ttmpl, ok := config.GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"缺少 deepseek provider 模板\")\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{ServiceType: \"claude\"},\n\t}}\n\tmissing := missingProviderAccountRoutes(tmpl, existing)\n\tif len(missing) != 2 || missing[0].ChannelKind != \"chat\" || missing[1].ChannelKind != \"responses\" {\n\t\tt.Fatalf(\"missing routes = %+v\", missing)\n\t}\n\tconfigs, baseURLs, err := bindProviderRouteKeys(tmpl, missing[0], []string{\"sk-existing\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(configs) != 1 || configs[0].BaseURL != \"https://api.deepseek.com\" || len(baseURLs) != 1 {\n\t\tt.Fatalf(\"route 绑定结果不正确: configs=%+v baseURLs=%v\", configs, baseURLs)\n\t}\n}\n\nfunc TestBindProviderRouteKeysPreservesExistingEndpointAffinity(t *testing.T) {\n\ttests := []struct {\n\t\tproviderID string\n\t\tapiKey string\n\t\texistingBaseURL string\n\t\ttargetKind string\n\t\twantBaseURL string\n\t}{\n\t\t{\n\t\t\tproviderID: \"mimo\", apiKey: \"tp-existing\",\n\t\t\texistingBaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\t\ttargetKind: \"chat\", wantBaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\",\n\t\t},\n\t\t{\n\t\t\tproviderID: \"volcengine\", apiKey: \"ark-existing\",\n\t\t\texistingBaseURL: \"https://ark.cn-beijing.volces.com/api/coding\",\n\t\t\ttargetKind: \"responses\", wantBaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.providerID, func(t *testing.T) {\n\t\t\ttmpl, ok := config.GetProviderTemplate(tt.providerID)\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"缺少 provider 模板: %s\", tt.providerID)\n\t\t\t}\n\t\t\texisting := []config.AccountChannel{{\n\t\t\t\tKind: \"messages\",\n\t\t\t\tUpstream: config.UpstreamConfig{\n\t\t\t\t\tProviderID: tt.providerID, ServiceType: \"claude\", APIKeys: []string{tt.apiKey},\n\t\t\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: tt.apiKey, BaseURL: tt.existingBaseURL}},\n\t\t\t\t},\n\t\t\t}}\n\t\t\tvar target config.ProviderRoute\n\t\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\t\tif route.ChannelKind == tt.targetKind {\n\t\t\t\t\ttarget = route\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tconfigs, _, err := bindProviderRouteKeysWithAffinities(\n\t\t\t\ttmpl, target, []string{tt.apiKey}, providerKeyCandidateAffinities(tmpl, existing),\n\t\t\t)\n\t\t\tif err != nil || len(configs) != 1 || configs[0].BaseURL != tt.wantBaseURL {\n\t\t\t\tt.Fatalf(\"亲和绑定错误: configs=%+v err=%v want=%s\", configs, err, tt.wantBaseURL)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPlanProviderAccountRouteAdditionsRejectsNewKeyBeforeMutation(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t}))\n\tdefer server.Close()\n\n\ttmpl := &config.ProviderTemplate{\n\t\tProviderID: \"test-provider\",\n\t\tRoutes: []config.ProviderRoute{\n\t\t\t{ChannelKind: \"messages\", ServiceType: \"claude\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/anthropic\"}}},\n\t\t\t{ChannelKind: \"chat\", ServiceType: \"openai\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/v1\"}}},\n\t\t},\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct_test\", ChannelUID: \"ch_messages\", Name: \"test-provider\",\n\t\t\tProviderID: \"test-provider\", ServiceType: \"claude\", AutoManaged: true, APIKeys: []string{\"sk-existing\"},\n\t\t},\n\t}}\n\tadditions, status, err := planProviderAccountRouteAdditions(\n\t\tt.Context(), config.Config{}, tmpl, \"acct_test\", []string{\"sk-existing\", \"sk-invalid\"}, existing,\n\t)\n\tif err == nil || status != http.StatusBadRequest || len(additions) != 0 {\n\t\tt.Fatalf(\"无效新 Key 应在生成新增 route 前失败: status=%d additions=%+v err=%v\", status, additions, err)\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}, "48488333f5455eef9b0999cee1eaf5bb63dd23d6": {"t": 1785746092.858666, "n": 68677, "h": "9a46edf960bd44dc", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/BenedictKing/ccx/internal/errutil\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestListAccountsMasksCredentials(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-secret-value\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil)\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"sk-secret-value\") {\n\t\tt.Fatalf(\"账号列表泄露明文 Key: %s\", body)\n\t}\n\tif !strings.Contains(body, \"cred_test\") || !strings.Contains(body, \"keyMask\") {\n\t\tt.Fatalf(\"账号列表缺少凭证掩码信息: %s\", body)\n\t}\n}\n\nfunc TestListAccountsIncludesActiveEndpointModelAvailability(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"volcengine\",\"name\":\"volcengine\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"ark-secret-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"ark-secret-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"volcengine\",\"name\":\"volcengine-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://example.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tstore, err := NewProfileStoreWithDB(newTestDB(t))\n\tif err != nil {\n\t\tt.Fatalf(\"NewProfileStoreWithDB 失败: %v\", err)\n\t}\n\tupdatedAt := time.Date(2026, 7, 17, 10, 54, 33, 0, time.UTC)\n\tdiscoveredAt := updatedAt\n\tdiscoveredAtLater := updatedAt.Add(time.Minute)\n\tfor _, profile := range []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_a\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_a\", KeyMask: \"ark-a***001\", AvailableModels: []string{\"model-b\", \"model-a\"}, UpdatedAt: updatedAt,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI, ModelsDiscoveredAt: &discoveredAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_b\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_b\", KeyMask: \"ark-b***002\", AvailableModels: []string{\"model-c\", \"model-b\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI, ModelsDiscoveredAt: &discoveredAtLater,\n\t\t},\n\t} {\n\t\tif err := store.Upsert(profile); err != nil {\n\t\t\tt.Fatalf(\"ProfileStore.Upsert 失败: %v\", err)\n\t\t}\n\t}\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager, Runner: runner})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response struct {\n\t\tAccounts []managedAccountView `json:\"accounts\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatalf(\"解析响应失败: %v\", err)\n\t}\n\tif len(response.Accounts) != 1 || len(response.Accounts[0].Channels) != 1 {\n\t\tt.Fatalf(\"账号渠道数量错误: %+v\", response.Accounts)\n\t}\n\tchannel := response.Accounts[0].Channels[0]\n\tif got, want := strings.Join(channel.DiscoveredModels, \",\"), \"model-a,model-b,model-c\"; got != want {\n\t\tt.Fatalf(\"DiscoveredModels=%q, want %q\", got, want)\n\t}\n\tif !channel.ModelInventoryKnown {\n\t\tt.Fatal(\"已发现模型时 ModelInventoryKnown 应为 true\")\n\t}\n\tif len(channel.ModelBindings) != 2 {\n\t\tt.Fatalf(\"ModelBindings=%+v, want 2\", channel.ModelBindings)\n\t}\n\tif got := strings.Join(channel.ModelBindings[0].Models, \",\"); got != \"model-a,model-b\" {\n\t\tt.Fatalf(\"第一个 Key 模型=%q\", got)\n\t}\n\tif channel.ModelsUpdatedAt == nil || !channel.ModelsUpdatedAt.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"ModelsUpdatedAt=%v\", channel.ModelsUpdatedAt)\n\t}\n\tif strings.Contains(w.Body.String(), \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", w.Body.String())\n\t}\n}\n\nfunc TestListAccountsIncludesCustomAutoManagedProtocolAvailability(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [],\n \"upstream\": [], \"chatUpstream\": [],\n \"responsesUpstream\": [{\"accountUid\":\"acct_custom\",\"channelUid\":\"ch_custom\",\"name\":\"custom-endpoint\",\"serviceType\":\"responses\",\"baseUrl\":\"https://example.com\",\"apiKeys\":[\"sk-custom-secret\"],\"apiKeyConfigs\":[{\"key\":\"sk-custom-secret\",\"credentialUid\":\"cred_custom\",\"baseUrl\":\"https://example.com\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tstore, err := NewProfileStoreWithDB(newTestDB(t))\n\tif err != nil {\n\t\tt.Fatalf(\"NewProfileStoreWithDB 失败: %v\", err)\n\t}\n\tdiscoveredAt := time.Date(2026, 7, 25, 5, 11, 29, 0, time.UTC)\n\tprofile := &KeyEndpointProfile{\n\t\tEndpointUID: \"ep_custom\", AccountUID: \"acct_custom\", ChannelUID: \"ch_custom\",\n\t\tCredentialUID: \"cred_custom\", KeyMask: \"sk-c***ret\", ServiceType: \"responses\",\n\t\tAvailableModels: []string{\"gpt-5.6-sol\"}, ModelsDiscoveredAt: &discoveredAt,\n\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI,\n\t\tProtocolModels: map[string][]string{\n\t\t\t\"messages\": {\"gpt-5.6-sol\"},\n\t\t\t\"chat\": {\"gpt-5.6-sol\"},\n\t\t\t\"responses\": {\"gpt-5.6-sol\"},\n\t\t},\n\t\tProtocolDiscoveredAt: map[string]time.Time{\n\t\t\t\"messages\": discoveredAt, \"chat\": discoveredAt, \"responses\": discoveredAt,\n\t\t},\n\t\tProtocolDiscoverySource: map[string]string{\n\t\t\t\"messages\": \"protocol_probe\", \"chat\": \"protocol_probe\", \"responses\": \"models_api\",\n\t\t},\n\t\tUpdatedAt: discoveredAt,\n\t}\n\tif err := store.Upsert(profile); err != nil {\n\t\tt.Fatalf(\"ProfileStore.Upsert 失败: %v\", err)\n\t}\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: cfgManager,\n\t\tRunner: NewAutoDiscoveryRunner(store, nil),\n\t})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tif strings.Contains(w.Body.String(), \"sk-custom-secret\") {\n\t\tt.Fatalf(\"账号列表泄露自定义渠道明文 Key: %s\", w.Body.String())\n\t}\n\n\tvar response struct {\n\t\tAccounts []managedAccountView `json:\"accounts\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatalf(\"解析响应失败: %v\", err)\n\t}\n\tif len(response.Accounts) != 1 {\n\t\tt.Fatalf(\"Accounts=%+v, want 1\", response.Accounts)\n\t}\n\taccount := response.Accounts[0]\n\tif account.AccountUID != \"acct_custom\" || len(account.Credentials) != 1 || len(account.Channels) != 1 {\n\t\tt.Fatalf(\"自定义自动托管账号聚合错误: %+v\", account)\n\t}\n\tprotocols := make([]string, 0, len(account.Channels[0].ProtocolAvailability))\n\tfor _, availability := range account.Channels[0].ProtocolAvailability {\n\t\tprotocols = append(protocols, availability.Protocol)\n\t}\n\tif got, want := strings.Join(protocols, \",\"), \"messages,chat,responses\"; got != want {\n\t\tt.Fatalf(\"ProtocolAvailability=%q, want %q\", got, want)\n\t}\n}\n\nfunc TestManagedChannelModelAvailabilityIncludesEmptyAutoDiscoveryResult(t *testing.T) {\n\tupdatedAt := time.Date(2026, 7, 17, 11, 2, 0, 0, time.UTC)\n\tmodels, bindings, latest, known := managedChannelModelAvailability([]*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_empty\", CredentialUID: \"cred_empty\", KeyMask: \"ark-e***001\",\n\t\t\tSource: \"auto_discovery\", AvailableModels: []string{}, UpdatedAt: updatedAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_models\", CredentialUID: \"cred_models\", KeyMask: \"ark-m***002\",\n\t\t\tAvailableModels: []string{\"model-a\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_unknown\", CredentialUID: \"cred_unknown\", KeyMask: \"ark-u***003\",\n\t\t\tSource: \"l1_passive\", UpdatedAt: updatedAt.Add(2 * time.Minute),\n\t\t},\n\t})\n\n\tif !known {\n\t\tt.Fatal(\"自动发现的空模型清单仍应标记为已知\")\n\t}\n\tif got, want := strings.Join(models, \",\"), \"model-a\"; got != want {\n\t\tt.Fatalf(\"models=%q, want %q\", got, want)\n\t}\n\tif len(bindings) != 2 {\n\t\tt.Fatalf(\"bindings=%+v, want only two known inventories\", bindings)\n\t}\n\tif got := bindings[0]; got.KeyMask != \"ark-e***001\" || len(got.Models) != 0 {\n\t\tt.Fatalf(\"empty discovery binding=%+v, want empty ark-e binding\", got)\n\t}\n\tif latest == nil || !latest.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"latest=%v, want discovered inventory update time\", latest)\n\t}\n}\n\nfunc TestManagedChannelModelAvailabilityIncludesDiscoveryMetadata(t *testing.T) {\n\tfirst := time.Date(2026, 7, 22, 0, 42, 12, 0, time.UTC)\n\tsecond := first.Add(time.Minute)\n\tinventory := managedChannelModelAvailabilityDetails([]*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep-a\",\n\t\t\tCredentialUID: \"cred-a\",\n\t\t\tKeyMask: \"ark-a***001\",\n\t\t\tAvailableModels: []string{\"glm-5.2\"},\n\t\t\tUpdatedAt: first,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceControlPlane,\n\t\t\tModelDiscoveryMessage: \"火山管控面 Coding Plan 模型清单\",\n\t\t\tModelsDiscoveredAt: &first,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep-b\",\n\t\t\tCredentialUID: \"cred-b\",\n\t\t\tKeyMask: \"ark-b***002\",\n\t\t\tAvailableModels: []string{\"deepseek-v4-pro\"},\n\t\t\tUpdatedAt: second,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceBuiltinFallback,\n\t\t\tModelDiscoveryMessage: \"管控面失败,已回退内置模型清单\",\n\t\t\tModelsDiscoveredAt: &second,\n\t\t},\n\t})\n\n\tif !inventory.known || inventory.source != \"mixed\" {\n\t\tt.Fatalf(\"inventory source=%q known=%v\", inventory.source, inventory.known)\n\t}\n\tif !inventory.latestDiscoveredAt.Equal(second) || inventory.message != \"管控面失败,已回退内置模型清单\" {\n\t\tt.Fatalf(\"inventory discovery metadata=%+v\", inventory)\n\t}\n\tif len(inventory.bindings) != 2 || inventory.bindings[1].ModelsDiscoveredAt == nil {\n\t\tt.Fatalf(\"binding discovery metadata=%+v\", inventory.bindings)\n\t}\n}\n\nfunc TestManagedProtocolAvailabilityViewsAggregatesByProtocolAndCredential(t *testing.T) {\n\tdiscoveredAt := time.Date(2026, 7, 25, 10, 0, 0, 0, time.UTC)\n\tprofiles := []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep-a\", CredentialUID: \"cred-a\", KeyMask: \"sk-a***001\", ServiceType: \"responses\",\n\t\t\tUpdatedAt: discoveredAt, ProtocolModels: map[string][]string{\n\t\t\t\t\"responses\": {\"shared\", \"responses-only\"},\n\t\t\t\t\"chat\": {\"shared\"},\n\t\t\t},\n\t\t\tProtocolDiscoveredAt: map[string]time.Time{\"responses\": discoveredAt, \"chat\": discoveredAt},\n\t\t\tProtocolDiscoverySource: map[string]string{\"responses\": \"models_api\", \"chat\": \"protocol_probe\"},\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep-b\", CredentialUID: \"cred-b\", KeyMask: \"sk-b***002\", ServiceType: \"responses\",\n\t\t\tUpdatedAt: discoveredAt, ProtocolModels: map[string][]string{\n\t\t\t\t\"responses\": {\"shared\"},\n\t\t\t\t\"messages\": {\"shared\", \"messages-only\"},\n\t\t\t},\n\t\t\tProtocolDiscoveredAt: map[string]time.Time{\"responses\": discoveredAt, \"messages\": discoveredAt},\n\t\t\tProtocolDiscoverySource: map[string]string{\"responses\": \"models_api\", \"messages\": \"protocol_probe\"},\n\t\t},\n\t}\n\n\tviews := managedProtocolAvailabilityViews(profiles)\n\tif len(views) != 3 {\n\t\tt.Fatalf(\"views=%+v\", views)\n\t}\n\tif views[0].Protocol != \"messages\" || strings.Join(views[0].DiscoveredModels, \",\") != \"messages-only,shared\" {\n\t\tt.Fatalf(\"messages view=%+v\", views[0])\n\t}\n\tif views[2].Protocol != \"responses\" || len(views[2].ModelBindings) != 2 {\n\t\tt.Fatalf(\"responses view=%+v\", views[2])\n\t}\n}\n\nfunc TestPatchAccountCredentialsRemovesByUID(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"sk-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"sk-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"baseUrls\":[\"https://api.xiaomimimo.com/anthropic\"],\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPatch, \"/api/accounts/acct_test/credentials\", bytes.NewBufferString(`{\"removeCredentialUids\":[\"cred_b\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PATCH credentials status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 1 || len(channels[0].Upstream.APIKeys) != 1 || channels[0].Upstream.APIKeys[0] != \"sk-a\" {\n\t\tt.Fatalf(\"按 credentialUid 删除失败: %+v\", channels)\n\t}\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestInferAutoAddProviderID(t *testing.T) {\n\tzhipuKey := \"0123456789abcdef0123456789abcdef.ABCDEFGHIJKLMNO1\"\n\ttests := []struct {\n\t\tname string\n\t\tbaseURLs []string\n\t\tapiKeys []string\n\t\twant string\n\t}{\n\t\t{name: \"官方 Claude URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"官方 OpenAI URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4/\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"两个官方协议 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\", \"https://open.bigmodel.cn/api/paas/v4\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"仅智谱 Key\", apiKeys: []string{zhipuKey}, want: \"glm\"},\n\t\t{name: \"第三方 URL 不按 Key 提升\", baseURLs: []string{\"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"混合官方与第三方 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4\", \"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"共享 sk Key 无法推断\", apiKeys: []string{\"sk-abcdefghijklmnopqrstuvwxyz123456\"}},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := inferAutoAddProviderID(tt.baseURLs, tt.apiKeys); got != tt.want {\n\t\t\t\tt.Fatalf(\"inferAutoAddProviderID() = %q, want %q\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestApplyProviderUpstreamDefaults(t *testing.T) {\n\t// shouldPassbackReasoningContentByDefault 按 ProviderID+ServiceType 静态推导(GLM 已知\n\t// 协议事实),ApplyProviderUpstreamDefaults 不再写字段,测试必须设置 ProviderID 才能命中。\n\tglmChat := config.UpstreamConfig{ProviderID: \"glm\", ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmChat)\n\tif glmChat.ReasoningParamStyle != \"reasoning_effort\" || !glmChat.IsPassbackReasoningContentEnabled() {\n\t\tt.Fatalf(\"GLM OpenAI 默认兼容参数未补齐: %+v\", glmChat)\n\t}\n\n\tglmClaude := config.UpstreamConfig{ProviderID: \"glm\", ServiceType: \"claude\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmClaude)\n\tif glmClaude.ReasoningParamStyle != \"\" || glmClaude.IsPassbackReasoningContentEnabled() {\n\t\tt.Fatalf(\"GLM Claude 原生 route 不应注入 OpenAI 参数: %+v\", glmClaude)\n\t}\n\n\tcustom := config.UpstreamConfig{ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"\", &custom)\n\tif custom.ReasoningParamStyle != \"\" || custom.IsPassbackReasoningContentEnabled() {\n\t\tt.Fatalf(\"自定义渠道不应应用 GLM 默认值: %+v\", custom)\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCustomAutoAddResponseIncludesActualRoute(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\tdiscoverer := NewRateLimitDiscoverer(RateLimitDiscovererConfig{PassiveAimdEnabled: true, QuietLogs: true})\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, RateLimitDiscoverer: discoverer})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"fastaitoken-com-test\",\"baseUrls\":[\"https://example.com\"],\"apiKeys\":[\"sk-test\"],\"rateLimitHint\":{\"initialRpm\":30,\"effectiveRpm\":15,\"rateLimited\":true,\"rateLimitedCount\":1}}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 {\n\t\tt.Fatalf(\"channels=%+v, want one actual route\", response.Channels)\n\t}\n\troute := response.Channels[0]\n\tif route.ChannelKind != \"responses\" || route.ServiceType != \"responses\" || route.ChannelUID != response.ChannelUID {\n\t\tt.Fatalf(\"route=%+v response=%+v\", route, response)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.ResponsesUpstream) != 1 || len(cfg.Upstream) != 0 {\n\t\tt.Fatalf(\"custom route persisted in wrong channel: responses=%d messages=%d\", len(cfg.ResponsesUpstream), len(cfg.Upstream))\n\t}\n\tcreated := cfg.ResponsesUpstream[0]\n\tbaseURL := created.GetEffectiveBaseURL()\n\tendpointUID := GenerateEndpointUID(created.ChannelUID, baseURL, KeyHashFromAPIKey(\"sk-test\"))\n\tif suggestion := discoverer.SuggestedLimit(endpointUID); suggestion.RPM != 15 || suggestion.Confidence != 0.7 {\n\t\tt.Fatalf(\"rate limit suggestion=%+v, want rpm=15 confidence=0.7\", suggestion)\n\t}\n\n\tstore := newTestProfileStore(t)\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\tseedAutoAddRateLimitHint(\n\t\t&AutoManagedDeps{CfgManager: manager, Runner: runner, RateLimitDiscoverer: discoverer},\n\t\tmanager.GetAccountChannels(response.AccountUID),\n\t\t&AutoAddRateLimitHint{InitialRPM: 30, EffectiveRPM: 15, RateLimited: true, RateLimitedCount: 1},\n\t\t\"sk-test\",\n\t\t\"https://example.com\",\n\t)\n\tprofile := store.Get(endpointUID)\n\tif profile == nil || profile.DiscoveredRPM != 15 || profile.RateLimitConfidence != 0.7 || profile.RateLimitSource != string(RateLimitSourcePassiveAIMD) {\n\t\tt.Fatalf(\"rate limit profile=%+v\", profile)\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyToEquivalentExistingBaseURL(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [],\n \"chatUpstream\": [{\"channelUid\":\"ch_existing\",\"name\":\"localhost-old-name\",\"serviceType\":\"openai\",\"baseUrl\":\"http://localhost:8990/v1\",\"apiKeys\":[\"sk-old\"],\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"localhost-8990\",\"baseUrls\":[\"http://localhost:8990/\"],\"apiKeys\":[\"sk-new\"]}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 || response.Channels[0].Name != \"localhost-old-name\" {\n\t\tt.Fatalf(\"应返回已有渠道: %+v\", response.Channels)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 0 || len(cfg.ChatUpstream) != 1 {\n\t\tt.Fatalf(\"重复请求不应创建新渠道: messages=%d chat=%d\", len(cfg.Upstream), len(cfg.ChatUpstream))\n\t}\n\tif got := strings.Join(cfg.ChatUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"已有渠道密钥=%q, want sk-old,sk-new\", got)\n\t}\n}\n\nfunc TestFindExistingAutoAddChannelsPreservesHashSemantics(t *testing.T) {\n\tcfg := config.Config{\n\t\tChatUpstream: []config.UpstreamConfig{{\n\t\t\tChannelUID: \"ch-hash\", ServiceType: \"openai\", BaseURL: \"https://example.com#\",\n\t\t}},\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com\"}); len(matches) != 0 {\n\t\tt.Fatalf(\"普通 Base URL 不应匹配禁止版本前缀的地址: %+v\", matches)\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com/#\"}); len(matches) != 1 {\n\t\tt.Fatalf(\"等效 # 地址匹配数量=%d, want 1\", len(matches))\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyAndMissingRouteToManagedAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [{\"accountUid\":\"acct_existing\",\"channelUid\":\"ch_existing\",\"name\":\"localhost-8990\",\"serviceType\":\"claude\",\"baseUrl\":\"http://localhost:8990\",\"apiKeys\":[\"sk-old\"],\"autoManaged\":true,\"status\":\"active\"}],\n \"chatUpstream\": [], \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"localhost-8990\",\n \"baseUrls\":[\"http://localhost:8990/v1\"],\n \"apiKeys\":[\"sk-new\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"claude-test\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"gpt-test\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d\", len(cfg.Upstream), len(cfg.ResponsesUpstream))\n\t}\n\tif cfg.Upstream[0].AccountUID != \"acct_existing\" || cfg.ResponsesUpstream[0].AccountUID != \"acct_existing\" {\n\t\tt.Fatalf(\"应复用已有账号: messages=%q responses=%q\", cfg.Upstream[0].AccountUID, cfg.ResponsesUpstream[0].AccountUID)\n\t}\n\tif got := strings.Join(cfg.Upstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"messages 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif got := strings.Join(cfg.ResponsesUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"responses 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif cfg.Upstream[0].Name != \"localhost-8990-claude\" || cfg.ResponsesUpstream[0].Name != \"localhost-8990-codex\" {\n\t\tt.Fatalf(\"多协议自动命名错误: messages=%q responses=%q\", cfg.Upstream[0].Name, cfg.ResponsesUpstream[0].Name)\n\t}\n}\n\nfunc TestCustomAutoAddCreatesAllDetectedRoutesWithProtocolModels(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"fastaitoken-com-test\",\n \"baseUrls\":[\"https://example.com/keys\"],\n \"apiKeys\":[\"sk-test\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"shared\",\"messages-only\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"shared\",\"responses-only\"]},\n {\"channelKind\":\"chat\",\"supportedModels\":[\"shared\",\"chat-only\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 3 {\n\t\tt.Fatalf(\"channels=%+v, want three detected routes\", response.Channels)\n\t}\n\tfor _, route := range response.Channels {\n\t\tif route.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route account=%q, want %q\", route.AccountUID, response.AccountUID)\n\t\t}\n\t}\n\tif response.Channels[1].ChannelKind != \"responses\" || response.ChannelUID != response.Channels[1].ChannelUID {\n\t\tt.Fatalf(\"请求协议应作为主路由: response=%+v\", response)\n\t}\n\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 || len(cfg.ChatUpstream) != 1 || len(cfg.GeminiUpstream) != 0 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d chat=%d gemini=%d\",\n\t\t\tlen(cfg.Upstream), len(cfg.ResponsesUpstream), len(cfg.ChatUpstream), len(cfg.GeminiUpstream))\n\t}\n\tchecks := []struct {\n\t\tchannel config.UpstreamConfig\n\t\tname string\n\t\tmodels []string\n\t}{\n\t\t{cfg.Upstream[0], \"fastaitoken-com-test-claude\", []string{\"shared\", \"messages-only\"}},\n\t\t{cfg.ResponsesUpstream[0], \"fastaitoken-com-test-codex\", []string{\"shared\", \"responses-only\"}},\n\t\t{cfg.ChatUpstream[0], \"fastaitoken-com-test-chat\", []string{\"shared\", \"chat-only\"}},\n\t}\n\tfor _, check := range checks {\n\t\tif check.channel.Name != check.name || check.channel.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route identity=%+v, want name=%q account=%q\", check.channel, check.name, response.AccountUID)\n\t\t}\n\t\tif strings.Join(check.channel.SupportedModels, \",\") != strings.Join(check.models, \",\") {\n\t\t\tt.Fatalf(\"route %s models=%v, want %v\", check.name, check.channel.SupportedModels, check.models)\n\t\t}\n\t}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesRenamesAndSyncsCredentials(t *testing.T) {\n\tchannels := []config.AccountChannel{\n\t\t{Kind: \"messages\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-messages\", Name: \"old-claude\",\n\t\t\tServiceType: \"claude\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t\t{Kind: \"responses\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-responses\", Name: \"old-codex\",\n\t\t\tServiceType: \"responses\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t}\n\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\tName: \"renamed\", APIKeys: []string{\"sk-old\", \"sk-new\"},\n\t}, channels, len(channels))\n\tif err != nil || status != http.StatusOK {\n\t\tt.Fatalf(\"status=%d err=%v\", status, err)\n\t}\n\tif len(updates) != 2 || updates[0].Name != \"renamed-claude\" || updates[1].Name != \"renamed-codex\" {\n\t\tt.Fatalf(\"route names=%+v\", updates)\n\t}\n\tfor _, update := range updates {\n\t\tif len(update.APIKeyConfig) != 2 || update.APIKeyConfig[1].BaseURL != \"https://example.com\" {\n\t\t\tt.Fatalf(\"route credentials=%+v\", update.APIKeyConfig)\n\t\t}\n\t\tif update.APIKeyConfig[1].CredentialUID == \"\" {\n\t\t\tt.Fatalf(\"new credential missing stable uid: %+v\", update.APIKeyConfig[1])\n\t\t}\n\t}\n}\n\n// newCustomManagedChannel 构造一条自定义自动托管渠道,用于地址池编辑链路的表驱动断言。\nfunc newCustomManagedChannel(kind, serviceType string, baseURLs []string, keyConfigs []config.APIKeyConfig) config.AccountChannel {\n\tapiKeys := make([]string, 0, len(keyConfigs))\n\tfor _, keyConfig := range keyConfigs {\n\t\tapiKeys = append(apiKeys, keyConfig.Key)\n\t}\n\tprimary := \"\"\n\tif len(baseURLs) > 0 {\n\t\tprimary = baseURLs[0]\n\t}\n\treturn config.AccountChannel{Kind: kind, Upstream: config.UpstreamConfig{\n\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-\" + kind, Name: \"custom\" + accountRouteSuffix(kind),\n\t\tServiceType: serviceType, AutoManaged: true, BaseURL: primary,\n\t\tBaseURLs: append([]string(nil), baseURLs...), APIKeys: apiKeys,\n\t\tAPIKeyConfigs: append([]config.APIKeyConfig(nil), keyConfigs...),\n\t}}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesBaseURLPool(t *testing.T) {\n\tconst (\n\t\turlA = \"https://a.example.com\"\n\t\turlB = \"https://b.example.com\"\n\t\turlC = \"https://c.example.com\"\n\t)\n\ttests := []struct {\n\t\tname string\n\t\t// existingURLs / existingKeys 描述渠道现状,requestURLs 为请求里的 baseUrls(nil 表示未提供)\n\t\texistingURLs []string\n\t\texistingKeys []config.APIKeyConfig\n\t\tapiKeys []string\n\t\trequestURLs []string\n\t\twantStatus int\n\t\twantErr bool\n\t\twantBaseURLs []string\n\t\t// wantBindings 与 apiKeys 顺序一致,空串表示该 Key 未绑定端点\n\t\twantBindings []string\n\t}{\n\t\t{\n\t\t\tname: \"单地址扩为两地址\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA, urlB},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlB},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"两地址删除其中一个且保留有效绑定\",\n\t\t\texistingURLs: []string{urlA, urlB},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"地址顺序变化改变首地址\",\n\t\t\texistingURLs: []string{urlA, urlB},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlB, urlA},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlB, urlA},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"尾部斜杠与默认版本前缀等价地址去重\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA, urlA + \"/\", urlA + \"/v1\", \" \" + urlB + \"/ \", urlB},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlB},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"全部非法地址被拒绝\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{\"\", \" \", \"/\"},\n\t\t\twantStatus: http.StatusBadRequest,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"未提供地址时沿用渠道现有地址\",\n\t\t\texistingURLs: []string{urlA, urlB},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlB}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: nil,\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlB},\n\t\t\twantBindings: []string{urlB},\n\t\t},\n\t\t{\n\t\t\tname: \"绑定指向已删除地址时迁移到新首地址\",\n\t\t\texistingURLs: []string{urlA, urlB},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlB}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA, urlC},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlC},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"原本未绑定的凭证保持未绑定\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\"}, {Key: \"sk-2\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\", \"sk-2\", \"sk-new\"},\n\t\t\trequestURLs: []string{urlB, urlA},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlB, urlA},\n\t\t\t// sk-1 未绑定保持为空;sk-2 绑定仍在池内保持不变;新增 sk-new 绑定首地址\n\t\t\twantBindings: []string{\"\", urlA, urlB},\n\t\t},\n\t\t{\n\t\t\tname: \"非规范绑定被折叠为 canonical 形式\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA + \"/v1/\"}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA, urlB},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlB},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tchannels := []config.AccountChannel{\n\t\t\t\tnewCustomManagedChannel(\"messages\", \"claude\", tt.existingURLs, tt.existingKeys),\n\t\t\t}\n\t\t\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\t\t\tName: \"custom\", APIKeys: tt.apiKeys, BaseURLs: tt.requestURLs,\n\t\t\t}, channels, len(channels))\n\t\t\tif status != tt.wantStatus || (err != nil) != tt.wantErr {\n\t\t\t\tt.Fatalf(\"status=%d err=%v, want status=%d err=%v\", status, err, tt.wantStatus, tt.wantErr)\n\t\t\t}\n\t\t\tif tt.wantErr {\n\t\t\t\tif len(updates) != 0 {\n\t\t\t\t\tt.Fatalf(\"非法输入不应产生渠道更新: %+v\", updates)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(updates) != 1 {\n\t\t\t\tt.Fatalf(\"updates=%+v, want 1\", updates)\n\t\t\t}\n\t\t\tupdate := updates[0]\n\t\t\tif strings.Join(update.BaseURLs, \",\") != strings.Join(tt.wantBaseURLs, \",\") {\n\t\t\t\tt.Fatalf(\"BaseURLs=%v, want %v\", update.BaseURLs, tt.wantBaseURLs)\n\t\t\t}\n\t\t\tif len(update.APIKeyConfig) != len(tt.wantBindings) {\n\t\t\t\tt.Fatalf(\"APIKeyConfig=%+v, want %d 条\", update.APIKeyConfig, len(tt.wantBindings))\n\t\t\t}\n\t\t\tfor i, keyConfig := range update.APIKeyConfig {\n\t\t\t\tif keyConfig.Key != tt.apiKeys[i] {\n\t\t\t\t\tt.Fatalf(\"APIKeyConfig[%d].Key=%q, want %q\", i, keyConfig.Key, tt.apiKeys[i])\n\t\t\t\t}\n\t\t\t\tif keyConfig.BaseURL != tt.wantBindings[i] {\n\t\t\t\t\tt.Fatalf(\"APIKeyConfig[%d](%s).BaseURL=%q, want %q\", i, keyConfig.Key, keyConfig.BaseURL, tt.wantBindings[i])\n\t\t\t\t}\n\t\t\t\tif keyConfig.CredentialUID == \"\" {\n\t\t\t\t\tt.Fatalf(\"APIKeyConfig[%d] 缺少稳定 credentialUid: %+v\", i, keyConfig)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesSyncsBaseURLPoolAcrossProtocols(t *testing.T) {\n\tconst (\n\t\turlA = \"https://a.example.com\"\n\t\turlB = \"https://b.example.com\"\n\t)\n\texistingKeys := []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}}\n\tchannels := []config.AccountChannel{\n\t\tnewCustomManagedChannel(\"messages\", \"claude\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"chat\", \"openai\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"responses\", \"responses\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"gemini\", \"gemini\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"images\", \"openai\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"vectors\", \"openai\", []string{urlA}, existingKeys),\n\t}\n\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\tName: \"custom\", APIKeys: []string{\"sk-1\"}, BaseURLs: []string{urlB + \"/\", urlA},\n\t}, channels, len(channels))\n\tif err != nil || status != http.StatusOK {\n\t\tt.Fatalf(\"status=%d err=%v\", status, err)\n\t}\n\tif len(updates) != len(channels) {\n\t\tt.Fatalf(\"updates=%d, want %d\", len(updates), len(channels))\n\t}\n\twantPool := urlB + \",\" + urlA\n\tfor i, update := range updates {\n\t\tif got := strings.Join(update.BaseURLs, \",\"); got != wantPool {\n\t\t\tt.Fatalf(\"%s 渠道地址池=%q, want %q\", channels[i].Kind, got, wantPool)\n\t\t}\n\t\tif len(update.APIKeyConfig) != 1 || update.APIKeyConfig[0].BaseURL != urlA {\n\t\t\tt.Fatalf(\"%s 渠道凭证绑定=%+v, want %s\", channels[i].Kind, update.APIKeyConfig, urlA)\n\t\t}\n\t}\n}\n\nfunc TestUpdateAccountIgnoresBaseURLsForProviderManagedAccount(t *testing.T) {\n\tconst templateBaseURL = \"https://api.xiaomimimo.com/anthropic\"\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_provider\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_provider\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"` + templateBaseURL + `\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"` + templateBaseURL + `\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\tbody := `{\"name\":\"mimo-main\",\"apiKeys\":[\"sk-existing\"],\"baseUrls\":[\"https://manual-pool.example.com\"]}`\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_provider\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PUT /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_provider\")\n\tif len(channels) == 0 {\n\t\tt.Fatalf(\"provider 托管账号渠道丢失\")\n\t}\n\tfor _, accountChannel := range channels {\n\t\tchannel := accountChannel.Upstream\n\t\tif channel.BaseURL != templateBaseURL {\n\t\t\tt.Fatalf(\"%s 渠道主地址被手工地址覆盖: %q\", accountChannel.Kind, channel.BaseURL)\n\t\t}\n\t\tfor _, baseURL := range channel.GetAllBaseURLs() {\n\t\t\tif strings.Contains(baseURL, \"manual-pool\") {\n\t\t\t\tt.Fatalf(\"%s 渠道地址池混入手工地址: %v\", accountChannel.Kind, channel.GetAllBaseURLs())\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestProviderRouteNameAndPrimaryResult(t *testing.T) {\n\tbase := \"mimo-test\"\n\ttests := []struct {\n\t\troute config.ProviderRoute\n\t\twant string\n\t}{\n\t\t{route: config.ProviderRoute{ChannelKind: \"messages\"}, want: \"mimo-test-claude\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"chat\"}, want: \"mimo-test-chat\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"responses\"}, want: \"mimo-test-codex\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"gemini\"}, want: \"mimo-test-gemini\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := providerRouteName(base, tt.route, true); got != tt.want {\n\t\t\tt.Fatalf(\"providerRouteName(%s)=%q, want %q\", tt.route.ChannelKind, got, tt.want)\n\t\t}\n\t}\n\tif got := providerRouteName(base, config.ProviderRoute{ChannelKind: \"messages\"}, false); got != base {\n\t\tt.Fatalf(\"single route name=%q, want %q\", got, base)\n\t}\n\n\tresults := []AutoAddChannelResult{\n\t\t{ChannelKind: \"messages\", ChannelUID: \"ch_messages\", Index: 1},\n\t\t{ChannelKind: \"chat\", ChannelUID: \"ch_chat\", Index: 2},\n\t\t{ChannelKind: \"responses\", ChannelUID: \"ch_responses\", Index: 3},\n\t}\n\tprimary := primaryAutoAddResult(results, \"chat\")\n\tif primary.ChannelUID != \"ch_chat\" || primary.Index != 2 {\n\t\tt.Fatalf(\"primary=%+v, want chat result\", primary)\n\t}\n}\n\nfunc TestManagedAccountUIDForProviderUsesExistingAccount(t *testing.T) {\n\tcfg := config.Config{ManagedAccounts: []config.ManagedAccountConfig{\n\t\t{AccountUID: \"acct-old\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-new\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-deepseek\", ProviderID: \"deepseek\"},\n\t}}\n\tif got := managedAccountUIDForProvider(cfg, \"mimo\"); got != \"acct-new\" {\n\t\tt.Fatalf(\"mimo existing account = %q, want acct-new\", got)\n\t}\n\tif got := managedAccountUIDForProvider(cfg, \"unknown\"); got != \"\" {\n\t\tt.Fatalf(\"unknown provider 不应命中账号: %q\", got)\n\t}\n}\n\nfunc TestListManagedAccountsDoesNotExposeVolcengineSecret(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-secret\",\"volcengineAccessKey\":{\"accessKeyId\":\"AKIDEXAMPLE\",\"secretAccessKey\":\"SECRET_MUST_NOT_LEAK\",\"plan\":\"agent_plan\",\"planTier\":\"Large\",\"planStatus\":\"Running\"}}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"SECRET_MUST_NOT_LEAK\") || strings.Contains(body, \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", body)\n\t}\n\tfor _, expected := range []string{`\"hasVolcengineAccessKey\":true`, `\"volcenginePlan\":\"agent_plan\"`, `\"volcenginePlanTier\":\"Large\"`} {\n\t\tif !strings.Contains(body, expected) {\n\t\t\tt.Fatalf(\"响应缺少 %s: %s\", expected, body)\n\t\t}\n\t}\n}\n\nfunc TestKimiConsoleTokenLifecycleDoesNotExposeToken(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_kimi\",\"providerId\":\"kimi\",\"name\":\"kimi\",\"credentials\":[{\"credentialUid\":\"cred_kimi\",\"apiKey\":\"sk-kimi-secret\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_kimi\",\"channelUid\":\"ch_kimi\",\"providerId\":\"kimi\",\"name\":\"kimi\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.kimi.com/coding\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_kimi\",\"baseUrl\":\"https://api.kimi.com/coding\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tkimiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Authorization\") != \"Bearer web-session-secret\" {\n\t\t\tt.Fatalf(\"Kimi 请求未使用保存的会话令牌\")\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase kimiUsagesPath:\n\t\t\t_, _ = w.Write([]byte(`{\"usages\":[{\"scope\":\"FEATURE_CODING\",\"detail\":{\"limit\":\"100\",\"remaining\":\"80\",\"resetTime\":\"2026-07-27T16:13:43Z\"},\"limits\":[{\"window\":{\"duration\":300,\"timeUnit\":\"TIME_UNIT_MINUTE\"},\"detail\":{\"limit\":\"100\",\"remaining\":\"90\",\"resetTime\":\"2026-07-21T02:13:43Z\"}}]}],\"totalQuota\":{\"limit\":\"100\",\"remaining\":\"80\"}}`))\n\t\tcase kimiSubscriptionStatsPath:\n\t\t\t_, _ = w.Write([]byte(`{\"ratelimitCode5h\":{\"ratio\":0.1,\"enabled\":true},\"ratelimitCode7d\":{\"ratio\":0.2,\"enabled\":true},\"subscriptionBalance\":{\"feature\":\"FEATURE_OMNI\",\"type\":\"SUBSCRIPTION\",\"unit\":\"UNIT_CREDIT\",\"amountUsedRatio\":0.2,\"kimiCodeUsedRatio\":0.2,\"expireTime\":\"2026-08-20T16:14:07Z\"}}`))\n\t\tdefault:\n\t\t\thttp.NotFound(w, r)\n\t\t}\n\t}))\n\tdefer kimiServer.Close()\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: cfgManager,\n\t\tKimiConsoleClient: &KimiConsoleClient{\n\t\t\tHTTPClient: kimiServer.Client(), BaseURL: kimiServer.URL,\n\t\t},\n\t})\n\tput := httptest.NewRequest(\n\t\thttp.MethodPut,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token\",\n\t\tbytes.NewBufferString(`{\"accessToken\":\"Bearer web-session-secret\"}`),\n\t)\n\tput.Header.Set(\"Content-Type\", \"application/json\")\n\tputRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(putRecorder, put)\n\tif putRecorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"绑定 Kimi 令牌失败: status=%d body=%s\", putRecorder.Code, putRecorder.Body.String())\n\t}\n\tif strings.Contains(putRecorder.Body.String(), \"web-session-secret\") || strings.Contains(putRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"绑定响应泄露 Kimi 令牌: %s\", putRecorder.Body.String())\n\t}\n\tcredential, ok := cfgManager.GetManagedAccountCredential(\"acct_kimi\", \"cred_kimi\")\n\tif !ok || credential.KimiConsole == nil || credential.KimiConsole.AccessToken != \"web-session-secret\" {\n\t\tt.Fatalf(\"Kimi 令牌未持久化: %+v\", credential)\n\t}\n\tpersisted, err := os.ReadFile(configPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !strings.Contains(string(persisted), `\"accessToken\": \"web-session-secret\"`) {\n\t\tt.Fatalf(\"配置文件未保存 Kimi 令牌\")\n\t}\n\n\tlistRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(listRecorder, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif listRecorder.Code != http.StatusOK || strings.Contains(listRecorder.Body.String(), \"web-session-secret\") || strings.Contains(listRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"账号列表响应异常或泄露令牌: status=%d body=%s\", listRecorder.Code, listRecorder.Body.String())\n\t}\n\tif !strings.Contains(listRecorder.Body.String(), `\"hasKimiConsoleToken\":true`) || !strings.Contains(listRecorder.Body.String(), `\"kimiCodeUsage\"`) {\n\t\tt.Fatalf(\"账号列表缺少 Kimi 用量快照: %s\", listRecorder.Body.String())\n\t}\n\n\trefreshRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(refreshRecorder, httptest.NewRequest(\n\t\thttp.MethodPost,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token/refresh\",\n\t\tnil,\n\t))\n\tif refreshRecorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"刷新 Kimi 余量失败: status=%d body=%s\", refreshRecorder.Code, refreshRecorder.Body.String())\n\t}\n\tif strings.Contains(refreshRecorder.Body.String(), \"web-session-secret\") || strings.Contains(refreshRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"刷新响应泄露 Kimi 令牌: %s\", refreshRecorder.Body.String())\n\t}\n\n\tdeleteRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(deleteRecorder, httptest.NewRequest(\n\t\thttp.MethodDelete,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token\",\n\t\tnil,\n\t))\n\tif deleteRecorder.Code != http.StatusNoContent {\n\t\tt.Fatalf(\"解绑 Kimi 令牌失败: status=%d body=%s\", deleteRecorder.Code, deleteRecorder.Body.String())\n\t}\n\tcredential, _ = cfgManager.GetManagedAccountCredential(\"acct_kimi\", \"cred_kimi\")\n\tif credential.KimiConsole != nil {\n\t\tt.Fatalf(\"Kimi 令牌解绑后仍存在: %+v\", credential.KimiConsole)\n\t}\n\tpersisted, err = os.ReadFile(configPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(persisted), \"web-session-secret\") {\n\t\tt.Fatalf(\"解绑后配置文件仍包含 Kimi 令牌\")\n\t}\n}\n\nfunc TestSetVolcengineAccessKeyDetectsPlanBeforePersisting(t *testing.T) {\n\tcontrolPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Query().Get(\"Action\") != \"GetPersonalPlan\" {\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tvar body struct{ Plan string }\n\t\t_ = json.NewDecoder(r.Body).Decode(&body)\n\t\tif body.Plan == \"CodingPlan\" {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t_, _ = w.Write([]byte(`{\"ResponseMetadata\":{\"Error\":{\"Code\":\"ResourceNotFound.Plan\",\"Message\":\"not found\"}}}`))\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\"Result\":{\"PlanType\":\"Large\",\"Status\":\"Running\"}}`))\n\t}))\n\tdefer controlPlane.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-inference\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trunner := NewAutoDiscoveryRunner(nil, nil)\n\trunner.client = controlPlane.Client()\n\trunner.volcengineControlPlaneEndpoint = controlPlane.URL\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, Runner: runner})\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_volc/credentials/cred_volc/volcengine-access-key\", bytes.NewBufferString(`{\"accessKeyId\":\"AKID\",\"secretAccessKey\":\"SECRET\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"plan\":\"agent_plan\"`) {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_volc\", \"cred_volc\")\n\tif !ok || credential.VolcengineAccessKey == nil || credential.VolcengineAccessKey.SecretAccessKey != \"SECRET\" || credential.VolcengineAccessKey.Plan != \"agent_plan\" {\n\t\tt.Fatalf(\"凭证未正确持久化: %+v\", credential)\n\t}\n}\n\nfunc TestSetMiMoConsoleCookieRequiresConfirmationBeforeAdoptingKey(t *testing.T) {\n\tconst oldKey = \"tp-old-1234567890123456789012345678901234567890\"\n\tconst cookieKey = \"tp-new-1234567890123456789012345678901234567890\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != \"api-platform_serviceToken=session; userId=42\" {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase \"/api/v1/userProfile\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"userId\":\"42\"}}`))\n\t\tcase \"/api/v1/tokenPlan/detail\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"planCode\":\"max\",\"planName\":\"Max\",\"currentPeriodEnd\":\"2026-07-29 23:59:59\"}}`))\n\t\tcase \"/api/v1/tokenPlan/usage\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"monthUsage\":{\"percent\":0.25,\"items\":[{\"used\":25,\"limit\":100}]},\"usage\":{\"percent\":0.5,\"items\":[{\"used\":50,\"limit\":100}]}}}`))\n\t\tcase \"/api/v1/tokenPlan/apiKey/raw\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":\"` + cookieKey + `\"}`))\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}\n\t}))\n\tdefer console.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"credentials\":[{\"credentialUid\":\"cred_mimo\",\"apiKey\":\"` + oldKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_mimo\",\"channelUid\":\"ch_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_mimo\",\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\tdeps := &AutoManagedDeps{CfgManager: manager, MiMoConsoleClient: &MiMoConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL}}\n\trouter := setupAutoManagedRouter(deps)\n\tbody := `{\"cookie\":\"api-platform_serviceToken=session; userId=42\"}`\n\trequest := func(payload string) *httptest.ResponseRecorder {\n\t\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_mimo/credentials/cred_mimo/mimo-console-cookie\", bytes.NewBufferString(payload))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tw := httptest.NewRecorder()\n\t\trouter.ServeHTTP(w, req)\n\t\treturn w\n\t}\n\tw := request(body)\n\tif w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), `\"code\":\"mimo_cookie_key_mismatch\"`) {\n\t\tt.Fatalf(\"未要求确认: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ := manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != oldKey || credential.MiMoConsole != nil {\n\t\tt.Fatalf(\"确认前不应修改配置: %+v\", credential)\n\t}\n\tw = request(`{\"cookie\":\"api-platform_serviceToken=session; userId=42\",\"adoptCookieKey\":true}`)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"keyAdopted\":true`) {\n\t\tt.Fatalf(\"确认采用失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ = manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != cookieKey || credential.MiMoConsole == nil {\n\t\tt.Fatalf(\"确认后未采用 Cookie Key: %+v\", credential)\n\t}\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif strings.Contains(list.Body.String(), \"api-platform_serviceToken\") || strings.Contains(list.Body.String(), cookieKey) {\n\t\tt.Fatalf(\"账号列表泄漏 Cookie 或原始 Key: %s\", list.Body.String())\n\t}\n}\n\nfunc TestCompshareConsoleCookieBindingDoesNotExposeSecrets(t *testing.T) {\n\tconst currentKey = \"sk-cp-test-current\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != testCompshareCookie {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\n \"RetCode\": 0,\n \"UserPlans\": [{\n \"Code\": \"cppkg-test\", \"PlanCode\": \"cp-plan-test\", \"PlanName\": \"Pro\", \"DisplayName\": \"Pro 增强版\",\n \"LimitPer5h\": 3000, \"LimitPerWeek\": 7500, \"LimitPerMonth\": 19000,\n \"UsagePer5h\": 0, \"UsagePerWeek\": 100, \"UsagePerMonth\": 6496,\n \"ConcurrencyLimit\": 10, \"Status\": 1, \"ExpireAt\": 1785037981,\n \"Keys\": [{\"APIKey\": \"` + currentKey + `\"}]\n }]\n}`))\n\t}))\n\tdefer console.Close()\n\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_compshare\",\"providerId\":\"compshare\",\"name\":\"compshare\",\"credentials\":[{\"credentialUid\":\"cred_compshare\",\"apiKey\":\"` + currentKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_compshare\",\"channelUid\":\"ch_compshare\",\"providerId\":\"compshare\",\"name\":\"compshare\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://api.compshare.cn/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_compshare\",\"baseUrl\":\"https://api.compshare.cn/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: manager,\n\t\tCompshareConsoleClient: &CompshareConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL},\n\t})\n\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_compshare/credentials/cred_compshare/compshare-console-cookie\", bytes.NewBufferString(`{\"cookie\":\"`+testCompshareCookie+`\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"displayName\":\"Pro 增强版\"`) {\n\t\tt.Fatalf(\"绑定失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_compshare\", \"cred_compshare\")\n\tif !ok || credential.CompshareConsole == nil || credential.CompshareConsole.Cookie != testCompshareCookie {\n\t\tt.Fatalf(\"控制台套餐未持久化: %+v\", credential)\n\t}\n\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tresponse := list.Body.String()\n\tfor _, secret := range []string{\"U_JWT_TOKEN\", \"test-session\", \"test_user@console.compshare.cn\", \"org-test\", currentKey, \"cppkg-test\"} {\n\t\tif strings.Contains(response, secret) {\n\t\t\tt.Fatalf(\"账号列表泄漏敏感字段 %q: %s\", secret, response)\n\t\t}\n\t}\n\tfor _, expected := range []string{`\"hasCompshareConsoleCookie\":true`, `\"planCode\":\"cp-plan-test\"`, `\"monthlyUsage\":{\"used\":6496,\"limit\":19000`} {\n\t\tif !strings.Contains(response, expected) {\n\t\t\tt.Fatalf(\"账号列表缺少套餐字段 %s: %s\", expected, response)\n\t\t}\n\t}\n}\n\nfunc TestProviderAutoAddReusesExistingAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(`{\"providerId\":\"mimo\",\"apiKeys\":[\"sk-existing\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"追加已有 provider key status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 4 {\n\t\tt.Fatalf(\"旧账号应自动补齐模板中的协议渠道: %+v\", channels)\n\t}\n\tkinds := make(map[string]bool, len(channels))\n\tfor _, channel := range channels {\n\t\tkinds[channel.Kind] = true\n\t}\n\tfor _, kind := range []string{\"messages\", \"chat\", \"responses\", \"gemini\"} {\n\t\tif !kinds[kind] {\n\t\t\tt.Fatalf(\"旧账号缺少 %s route: %+v\", kind, channels)\n\t\t}\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.AccountUID != \"acct_test\" {\n\t\tt.Fatalf(\"响应未返回已有账号: body=%s err=%v\", w.Body.String(), err)\n\t}\n\tif len(response.Channels) != 4 {\n\t\tt.Fatalf(\"响应未返回补齐后的全部渠道: %+v\", response.Channels)\n\t}\n}\n\nfunc TestMissingProviderAccountRoutes(t *testing.T) {\n\ttmpl, ok := config.GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"缺少 deepseek provider 模板\")\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{ServiceType: \"claude\"},\n\t}}\n\tmissing := missingProviderAccountRoutes(tmpl, existing)\n\tif len(missing) != 2 || missing[0].ChannelKind != \"chat\" || missing[1].ChannelKind != \"responses\" {\n\t\tt.Fatalf(\"missing routes = %+v\", missing)\n\t}\n\tconfigs, baseURLs, err := bindProviderRouteKeys(tmpl, missing[0], []string{\"sk-existing\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(configs) != 1 || configs[0].BaseURL != \"https://api.deepseek.com\" || len(baseURLs) != 1 {\n\t\tt.Fatalf(\"route 绑定结果不正确: configs=%+v baseURLs=%v\", configs, baseURLs)\n\t}\n}\n\nfunc TestBindProviderRouteKeysPreservesExistingEndpointAffinity(t *testing.T) {\n\ttests := []struct {\n\t\tproviderID string\n\t\tapiKey string\n\t\texistingBaseURL string\n\t\ttargetKind string\n\t\twantBaseURL string\n\t}{\n\t\t{\n\t\t\tproviderID: \"mimo\", apiKey: \"tp-existing\",\n\t\t\texistingBaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\t\ttargetKind: \"chat\", wantBaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\",\n\t\t},\n\t\t{\n\t\t\tproviderID: \"volcengine\", apiKey: \"ark-existing\",\n\t\t\texistingBaseURL: \"https://ark.cn-beijing.volces.com/api/coding\",\n\t\t\ttargetKind: \"responses\", wantBaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.providerID, func(t *testing.T) {\n\t\t\ttmpl, ok := config.GetProviderTemplate(tt.providerID)\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"缺少 provider 模板: %s\", tt.providerID)\n\t\t\t}\n\t\t\texisting := []config.AccountChannel{{\n\t\t\t\tKind: \"messages\",\n\t\t\t\tUpstream: config.UpstreamConfig{\n\t\t\t\t\tProviderID: tt.providerID, ServiceType: \"claude\", APIKeys: []string{tt.apiKey},\n\t\t\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: tt.apiKey, BaseURL: tt.existingBaseURL}},\n\t\t\t\t},\n\t\t\t}}\n\t\t\tvar target config.ProviderRoute\n\t\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\t\tif route.ChannelKind == tt.targetKind {\n\t\t\t\t\ttarget = route\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tconfigs, _, err := bindProviderRouteKeysWithAffinities(\n\t\t\t\ttmpl, target, []string{tt.apiKey}, providerKeyCandidateAffinities(tmpl, existing),\n\t\t\t)\n\t\t\tif err != nil || len(configs) != 1 || configs[0].BaseURL != tt.wantBaseURL {\n\t\t\t\tt.Fatalf(\"亲和绑定错误: configs=%+v err=%v want=%s\", configs, err, tt.wantBaseURL)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPlanProviderAccountRouteAdditionsRejectsNewKeyBeforeMutation(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t}))\n\tdefer server.Close()\n\n\ttmpl := &config.ProviderTemplate{\n\t\tProviderID: \"test-provider\",\n\t\tRoutes: []config.ProviderRoute{\n\t\t\t{ChannelKind: \"messages\", ServiceType: \"claude\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/anthropic\"}}},\n\t\t\t{ChannelKind: \"chat\", ServiceType: \"openai\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/v1\"}}},\n\t\t},\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct_test\", ChannelUID: \"ch_messages\", Name: \"test-provider\",\n\t\t\tProviderID: \"test-provider\", ServiceType: \"claude\", AutoManaged: true, APIKeys: []string{\"sk-existing\"},\n\t\t},\n\t}}\n\tadditions, status, err := planProviderAccountRouteAdditions(\n\t\tt.Context(), config.Config{}, tmpl, \"acct_test\", []string{\"sk-existing\", \"sk-invalid\"}, existing,\n\t)\n\tif err == nil || status != http.StatusBadRequest || len(additions) != 0 {\n\t\tt.Fatalf(\"无效新 Key 应在生成新增 route 前失败: status=%d additions=%+v err=%v\", status, additions, err)\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}, "27404268d29fef8c12a86299c10eb679edf2e97b": {"t": 1785759330.5636685, "n": 68677, "h": "9a46edf960bd44dc", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/BenedictKing/ccx/internal/errutil\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestListAccountsMasksCredentials(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-secret-value\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil)\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"sk-secret-value\") {\n\t\tt.Fatalf(\"账号列表泄露明文 Key: %s\", body)\n\t}\n\tif !strings.Contains(body, \"cred_test\") || !strings.Contains(body, \"keyMask\") {\n\t\tt.Fatalf(\"账号列表缺少凭证掩码信息: %s\", body)\n\t}\n}\n\nfunc TestListAccountsIncludesActiveEndpointModelAvailability(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"volcengine\",\"name\":\"volcengine\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"ark-secret-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"ark-secret-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"volcengine\",\"name\":\"volcengine-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://example.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tstore, err := NewProfileStoreWithDB(newTestDB(t))\n\tif err != nil {\n\t\tt.Fatalf(\"NewProfileStoreWithDB 失败: %v\", err)\n\t}\n\tupdatedAt := time.Date(2026, 7, 17, 10, 54, 33, 0, time.UTC)\n\tdiscoveredAt := updatedAt\n\tdiscoveredAtLater := updatedAt.Add(time.Minute)\n\tfor _, profile := range []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_a\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_a\", KeyMask: \"ark-a***001\", AvailableModels: []string{\"model-b\", \"model-a\"}, UpdatedAt: updatedAt,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI, ModelsDiscoveredAt: &discoveredAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_b\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_b\", KeyMask: \"ark-b***002\", AvailableModels: []string{\"model-c\", \"model-b\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI, ModelsDiscoveredAt: &discoveredAtLater,\n\t\t},\n\t} {\n\t\tif err := store.Upsert(profile); err != nil {\n\t\t\tt.Fatalf(\"ProfileStore.Upsert 失败: %v\", err)\n\t\t}\n\t}\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager, Runner: runner})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response struct {\n\t\tAccounts []managedAccountView `json:\"accounts\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatalf(\"解析响应失败: %v\", err)\n\t}\n\tif len(response.Accounts) != 1 || len(response.Accounts[0].Channels) != 1 {\n\t\tt.Fatalf(\"账号渠道数量错误: %+v\", response.Accounts)\n\t}\n\tchannel := response.Accounts[0].Channels[0]\n\tif got, want := strings.Join(channel.DiscoveredModels, \",\"), \"model-a,model-b,model-c\"; got != want {\n\t\tt.Fatalf(\"DiscoveredModels=%q, want %q\", got, want)\n\t}\n\tif !channel.ModelInventoryKnown {\n\t\tt.Fatal(\"已发现模型时 ModelInventoryKnown 应为 true\")\n\t}\n\tif len(channel.ModelBindings) != 2 {\n\t\tt.Fatalf(\"ModelBindings=%+v, want 2\", channel.ModelBindings)\n\t}\n\tif got := strings.Join(channel.ModelBindings[0].Models, \",\"); got != \"model-a,model-b\" {\n\t\tt.Fatalf(\"第一个 Key 模型=%q\", got)\n\t}\n\tif channel.ModelsUpdatedAt == nil || !channel.ModelsUpdatedAt.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"ModelsUpdatedAt=%v\", channel.ModelsUpdatedAt)\n\t}\n\tif strings.Contains(w.Body.String(), \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", w.Body.String())\n\t}\n}\n\nfunc TestListAccountsIncludesCustomAutoManagedProtocolAvailability(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [],\n \"upstream\": [], \"chatUpstream\": [],\n \"responsesUpstream\": [{\"accountUid\":\"acct_custom\",\"channelUid\":\"ch_custom\",\"name\":\"custom-endpoint\",\"serviceType\":\"responses\",\"baseUrl\":\"https://example.com\",\"apiKeys\":[\"sk-custom-secret\"],\"apiKeyConfigs\":[{\"key\":\"sk-custom-secret\",\"credentialUid\":\"cred_custom\",\"baseUrl\":\"https://example.com\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tstore, err := NewProfileStoreWithDB(newTestDB(t))\n\tif err != nil {\n\t\tt.Fatalf(\"NewProfileStoreWithDB 失败: %v\", err)\n\t}\n\tdiscoveredAt := time.Date(2026, 7, 25, 5, 11, 29, 0, time.UTC)\n\tprofile := &KeyEndpointProfile{\n\t\tEndpointUID: \"ep_custom\", AccountUID: \"acct_custom\", ChannelUID: \"ch_custom\",\n\t\tCredentialUID: \"cred_custom\", KeyMask: \"sk-c***ret\", ServiceType: \"responses\",\n\t\tAvailableModels: []string{\"gpt-5.6-sol\"}, ModelsDiscoveredAt: &discoveredAt,\n\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI,\n\t\tProtocolModels: map[string][]string{\n\t\t\t\"messages\": {\"gpt-5.6-sol\"},\n\t\t\t\"chat\": {\"gpt-5.6-sol\"},\n\t\t\t\"responses\": {\"gpt-5.6-sol\"},\n\t\t},\n\t\tProtocolDiscoveredAt: map[string]time.Time{\n\t\t\t\"messages\": discoveredAt, \"chat\": discoveredAt, \"responses\": discoveredAt,\n\t\t},\n\t\tProtocolDiscoverySource: map[string]string{\n\t\t\t\"messages\": \"protocol_probe\", \"chat\": \"protocol_probe\", \"responses\": \"models_api\",\n\t\t},\n\t\tUpdatedAt: discoveredAt,\n\t}\n\tif err := store.Upsert(profile); err != nil {\n\t\tt.Fatalf(\"ProfileStore.Upsert 失败: %v\", err)\n\t}\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: cfgManager,\n\t\tRunner: NewAutoDiscoveryRunner(store, nil),\n\t})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tif strings.Contains(w.Body.String(), \"sk-custom-secret\") {\n\t\tt.Fatalf(\"账号列表泄露自定义渠道明文 Key: %s\", w.Body.String())\n\t}\n\n\tvar response struct {\n\t\tAccounts []managedAccountView `json:\"accounts\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatalf(\"解析响应失败: %v\", err)\n\t}\n\tif len(response.Accounts) != 1 {\n\t\tt.Fatalf(\"Accounts=%+v, want 1\", response.Accounts)\n\t}\n\taccount := response.Accounts[0]\n\tif account.AccountUID != \"acct_custom\" || len(account.Credentials) != 1 || len(account.Channels) != 1 {\n\t\tt.Fatalf(\"自定义自动托管账号聚合错误: %+v\", account)\n\t}\n\tprotocols := make([]string, 0, len(account.Channels[0].ProtocolAvailability))\n\tfor _, availability := range account.Channels[0].ProtocolAvailability {\n\t\tprotocols = append(protocols, availability.Protocol)\n\t}\n\tif got, want := strings.Join(protocols, \",\"), \"messages,chat,responses\"; got != want {\n\t\tt.Fatalf(\"ProtocolAvailability=%q, want %q\", got, want)\n\t}\n}\n\nfunc TestManagedChannelModelAvailabilityIncludesEmptyAutoDiscoveryResult(t *testing.T) {\n\tupdatedAt := time.Date(2026, 7, 17, 11, 2, 0, 0, time.UTC)\n\tmodels, bindings, latest, known := managedChannelModelAvailability([]*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_empty\", CredentialUID: \"cred_empty\", KeyMask: \"ark-e***001\",\n\t\t\tSource: \"auto_discovery\", AvailableModels: []string{}, UpdatedAt: updatedAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_models\", CredentialUID: \"cred_models\", KeyMask: \"ark-m***002\",\n\t\t\tAvailableModels: []string{\"model-a\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_unknown\", CredentialUID: \"cred_unknown\", KeyMask: \"ark-u***003\",\n\t\t\tSource: \"l1_passive\", UpdatedAt: updatedAt.Add(2 * time.Minute),\n\t\t},\n\t})\n\n\tif !known {\n\t\tt.Fatal(\"自动发现的空模型清单仍应标记为已知\")\n\t}\n\tif got, want := strings.Join(models, \",\"), \"model-a\"; got != want {\n\t\tt.Fatalf(\"models=%q, want %q\", got, want)\n\t}\n\tif len(bindings) != 2 {\n\t\tt.Fatalf(\"bindings=%+v, want only two known inventories\", bindings)\n\t}\n\tif got := bindings[0]; got.KeyMask != \"ark-e***001\" || len(got.Models) != 0 {\n\t\tt.Fatalf(\"empty discovery binding=%+v, want empty ark-e binding\", got)\n\t}\n\tif latest == nil || !latest.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"latest=%v, want discovered inventory update time\", latest)\n\t}\n}\n\nfunc TestManagedChannelModelAvailabilityIncludesDiscoveryMetadata(t *testing.T) {\n\tfirst := time.Date(2026, 7, 22, 0, 42, 12, 0, time.UTC)\n\tsecond := first.Add(time.Minute)\n\tinventory := managedChannelModelAvailabilityDetails([]*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep-a\",\n\t\t\tCredentialUID: \"cred-a\",\n\t\t\tKeyMask: \"ark-a***001\",\n\t\t\tAvailableModels: []string{\"glm-5.2\"},\n\t\t\tUpdatedAt: first,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceControlPlane,\n\t\t\tModelDiscoveryMessage: \"火山管控面 Coding Plan 模型清单\",\n\t\t\tModelsDiscoveredAt: &first,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep-b\",\n\t\t\tCredentialUID: \"cred-b\",\n\t\t\tKeyMask: \"ark-b***002\",\n\t\t\tAvailableModels: []string{\"deepseek-v4-pro\"},\n\t\t\tUpdatedAt: second,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceBuiltinFallback,\n\t\t\tModelDiscoveryMessage: \"管控面失败,已回退内置模型清单\",\n\t\t\tModelsDiscoveredAt: &second,\n\t\t},\n\t})\n\n\tif !inventory.known || inventory.source != \"mixed\" {\n\t\tt.Fatalf(\"inventory source=%q known=%v\", inventory.source, inventory.known)\n\t}\n\tif !inventory.latestDiscoveredAt.Equal(second) || inventory.message != \"管控面失败,已回退内置模型清单\" {\n\t\tt.Fatalf(\"inventory discovery metadata=%+v\", inventory)\n\t}\n\tif len(inventory.bindings) != 2 || inventory.bindings[1].ModelsDiscoveredAt == nil {\n\t\tt.Fatalf(\"binding discovery metadata=%+v\", inventory.bindings)\n\t}\n}\n\nfunc TestManagedProtocolAvailabilityViewsAggregatesByProtocolAndCredential(t *testing.T) {\n\tdiscoveredAt := time.Date(2026, 7, 25, 10, 0, 0, 0, time.UTC)\n\tprofiles := []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep-a\", CredentialUID: \"cred-a\", KeyMask: \"sk-a***001\", ServiceType: \"responses\",\n\t\t\tUpdatedAt: discoveredAt, ProtocolModels: map[string][]string{\n\t\t\t\t\"responses\": {\"shared\", \"responses-only\"},\n\t\t\t\t\"chat\": {\"shared\"},\n\t\t\t},\n\t\t\tProtocolDiscoveredAt: map[string]time.Time{\"responses\": discoveredAt, \"chat\": discoveredAt},\n\t\t\tProtocolDiscoverySource: map[string]string{\"responses\": \"models_api\", \"chat\": \"protocol_probe\"},\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep-b\", CredentialUID: \"cred-b\", KeyMask: \"sk-b***002\", ServiceType: \"responses\",\n\t\t\tUpdatedAt: discoveredAt, ProtocolModels: map[string][]string{\n\t\t\t\t\"responses\": {\"shared\"},\n\t\t\t\t\"messages\": {\"shared\", \"messages-only\"},\n\t\t\t},\n\t\t\tProtocolDiscoveredAt: map[string]time.Time{\"responses\": discoveredAt, \"messages\": discoveredAt},\n\t\t\tProtocolDiscoverySource: map[string]string{\"responses\": \"models_api\", \"messages\": \"protocol_probe\"},\n\t\t},\n\t}\n\n\tviews := managedProtocolAvailabilityViews(profiles)\n\tif len(views) != 3 {\n\t\tt.Fatalf(\"views=%+v\", views)\n\t}\n\tif views[0].Protocol != \"messages\" || strings.Join(views[0].DiscoveredModels, \",\") != \"messages-only,shared\" {\n\t\tt.Fatalf(\"messages view=%+v\", views[0])\n\t}\n\tif views[2].Protocol != \"responses\" || len(views[2].ModelBindings) != 2 {\n\t\tt.Fatalf(\"responses view=%+v\", views[2])\n\t}\n}\n\nfunc TestPatchAccountCredentialsRemovesByUID(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"sk-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"sk-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"baseUrls\":[\"https://api.xiaomimimo.com/anthropic\"],\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPatch, \"/api/accounts/acct_test/credentials\", bytes.NewBufferString(`{\"removeCredentialUids\":[\"cred_b\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PATCH credentials status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 1 || len(channels[0].Upstream.APIKeys) != 1 || channels[0].Upstream.APIKeys[0] != \"sk-a\" {\n\t\tt.Fatalf(\"按 credentialUid 删除失败: %+v\", channels)\n\t}\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestInferAutoAddProviderID(t *testing.T) {\n\tzhipuKey := \"0123456789abcdef0123456789abcdef.ABCDEFGHIJKLMNO1\"\n\ttests := []struct {\n\t\tname string\n\t\tbaseURLs []string\n\t\tapiKeys []string\n\t\twant string\n\t}{\n\t\t{name: \"官方 Claude URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"官方 OpenAI URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4/\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"两个官方协议 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\", \"https://open.bigmodel.cn/api/paas/v4\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"仅智谱 Key\", apiKeys: []string{zhipuKey}, want: \"glm\"},\n\t\t{name: \"第三方 URL 不按 Key 提升\", baseURLs: []string{\"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"混合官方与第三方 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4\", \"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"共享 sk Key 无法推断\", apiKeys: []string{\"sk-abcdefghijklmnopqrstuvwxyz123456\"}},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := inferAutoAddProviderID(tt.baseURLs, tt.apiKeys); got != tt.want {\n\t\t\t\tt.Fatalf(\"inferAutoAddProviderID() = %q, want %q\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestApplyProviderUpstreamDefaults(t *testing.T) {\n\t// shouldPassbackReasoningContentByDefault 按 ProviderID+ServiceType 静态推导(GLM 已知\n\t// 协议事实),ApplyProviderUpstreamDefaults 不再写字段,测试必须设置 ProviderID 才能命中。\n\tglmChat := config.UpstreamConfig{ProviderID: \"glm\", ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmChat)\n\tif glmChat.ReasoningParamStyle != \"reasoning_effort\" || !glmChat.IsPassbackReasoningContentEnabled() {\n\t\tt.Fatalf(\"GLM OpenAI 默认兼容参数未补齐: %+v\", glmChat)\n\t}\n\n\tglmClaude := config.UpstreamConfig{ProviderID: \"glm\", ServiceType: \"claude\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmClaude)\n\tif glmClaude.ReasoningParamStyle != \"\" || glmClaude.IsPassbackReasoningContentEnabled() {\n\t\tt.Fatalf(\"GLM Claude 原生 route 不应注入 OpenAI 参数: %+v\", glmClaude)\n\t}\n\n\tcustom := config.UpstreamConfig{ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"\", &custom)\n\tif custom.ReasoningParamStyle != \"\" || custom.IsPassbackReasoningContentEnabled() {\n\t\tt.Fatalf(\"自定义渠道不应应用 GLM 默认值: %+v\", custom)\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCustomAutoAddResponseIncludesActualRoute(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\tdiscoverer := NewRateLimitDiscoverer(RateLimitDiscovererConfig{PassiveAimdEnabled: true, QuietLogs: true})\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, RateLimitDiscoverer: discoverer})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"fastaitoken-com-test\",\"baseUrls\":[\"https://example.com\"],\"apiKeys\":[\"sk-test\"],\"rateLimitHint\":{\"initialRpm\":30,\"effectiveRpm\":15,\"rateLimited\":true,\"rateLimitedCount\":1}}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 {\n\t\tt.Fatalf(\"channels=%+v, want one actual route\", response.Channels)\n\t}\n\troute := response.Channels[0]\n\tif route.ChannelKind != \"responses\" || route.ServiceType != \"responses\" || route.ChannelUID != response.ChannelUID {\n\t\tt.Fatalf(\"route=%+v response=%+v\", route, response)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.ResponsesUpstream) != 1 || len(cfg.Upstream) != 0 {\n\t\tt.Fatalf(\"custom route persisted in wrong channel: responses=%d messages=%d\", len(cfg.ResponsesUpstream), len(cfg.Upstream))\n\t}\n\tcreated := cfg.ResponsesUpstream[0]\n\tbaseURL := created.GetEffectiveBaseURL()\n\tendpointUID := GenerateEndpointUID(created.ChannelUID, baseURL, KeyHashFromAPIKey(\"sk-test\"))\n\tif suggestion := discoverer.SuggestedLimit(endpointUID); suggestion.RPM != 15 || suggestion.Confidence != 0.7 {\n\t\tt.Fatalf(\"rate limit suggestion=%+v, want rpm=15 confidence=0.7\", suggestion)\n\t}\n\n\tstore := newTestProfileStore(t)\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\tseedAutoAddRateLimitHint(\n\t\t&AutoManagedDeps{CfgManager: manager, Runner: runner, RateLimitDiscoverer: discoverer},\n\t\tmanager.GetAccountChannels(response.AccountUID),\n\t\t&AutoAddRateLimitHint{InitialRPM: 30, EffectiveRPM: 15, RateLimited: true, RateLimitedCount: 1},\n\t\t\"sk-test\",\n\t\t\"https://example.com\",\n\t)\n\tprofile := store.Get(endpointUID)\n\tif profile == nil || profile.DiscoveredRPM != 15 || profile.RateLimitConfidence != 0.7 || profile.RateLimitSource != string(RateLimitSourcePassiveAIMD) {\n\t\tt.Fatalf(\"rate limit profile=%+v\", profile)\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyToEquivalentExistingBaseURL(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [],\n \"chatUpstream\": [{\"channelUid\":\"ch_existing\",\"name\":\"localhost-old-name\",\"serviceType\":\"openai\",\"baseUrl\":\"http://localhost:8990/v1\",\"apiKeys\":[\"sk-old\"],\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"localhost-8990\",\"baseUrls\":[\"http://localhost:8990/\"],\"apiKeys\":[\"sk-new\"]}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 || response.Channels[0].Name != \"localhost-old-name\" {\n\t\tt.Fatalf(\"应返回已有渠道: %+v\", response.Channels)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 0 || len(cfg.ChatUpstream) != 1 {\n\t\tt.Fatalf(\"重复请求不应创建新渠道: messages=%d chat=%d\", len(cfg.Upstream), len(cfg.ChatUpstream))\n\t}\n\tif got := strings.Join(cfg.ChatUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"已有渠道密钥=%q, want sk-old,sk-new\", got)\n\t}\n}\n\nfunc TestFindExistingAutoAddChannelsPreservesHashSemantics(t *testing.T) {\n\tcfg := config.Config{\n\t\tChatUpstream: []config.UpstreamConfig{{\n\t\t\tChannelUID: \"ch-hash\", ServiceType: \"openai\", BaseURL: \"https://example.com#\",\n\t\t}},\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com\"}); len(matches) != 0 {\n\t\tt.Fatalf(\"普通 Base URL 不应匹配禁止版本前缀的地址: %+v\", matches)\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com/#\"}); len(matches) != 1 {\n\t\tt.Fatalf(\"等效 # 地址匹配数量=%d, want 1\", len(matches))\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyAndMissingRouteToManagedAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [{\"accountUid\":\"acct_existing\",\"channelUid\":\"ch_existing\",\"name\":\"localhost-8990\",\"serviceType\":\"claude\",\"baseUrl\":\"http://localhost:8990\",\"apiKeys\":[\"sk-old\"],\"autoManaged\":true,\"status\":\"active\"}],\n \"chatUpstream\": [], \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"localhost-8990\",\n \"baseUrls\":[\"http://localhost:8990/v1\"],\n \"apiKeys\":[\"sk-new\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"claude-test\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"gpt-test\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d\", len(cfg.Upstream), len(cfg.ResponsesUpstream))\n\t}\n\tif cfg.Upstream[0].AccountUID != \"acct_existing\" || cfg.ResponsesUpstream[0].AccountUID != \"acct_existing\" {\n\t\tt.Fatalf(\"应复用已有账号: messages=%q responses=%q\", cfg.Upstream[0].AccountUID, cfg.ResponsesUpstream[0].AccountUID)\n\t}\n\tif got := strings.Join(cfg.Upstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"messages 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif got := strings.Join(cfg.ResponsesUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"responses 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif cfg.Upstream[0].Name != \"localhost-8990-claude\" || cfg.ResponsesUpstream[0].Name != \"localhost-8990-codex\" {\n\t\tt.Fatalf(\"多协议自动命名错误: messages=%q responses=%q\", cfg.Upstream[0].Name, cfg.ResponsesUpstream[0].Name)\n\t}\n}\n\nfunc TestCustomAutoAddCreatesAllDetectedRoutesWithProtocolModels(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"fastaitoken-com-test\",\n \"baseUrls\":[\"https://example.com/keys\"],\n \"apiKeys\":[\"sk-test\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"shared\",\"messages-only\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"shared\",\"responses-only\"]},\n {\"channelKind\":\"chat\",\"supportedModels\":[\"shared\",\"chat-only\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 3 {\n\t\tt.Fatalf(\"channels=%+v, want three detected routes\", response.Channels)\n\t}\n\tfor _, route := range response.Channels {\n\t\tif route.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route account=%q, want %q\", route.AccountUID, response.AccountUID)\n\t\t}\n\t}\n\tif response.Channels[1].ChannelKind != \"responses\" || response.ChannelUID != response.Channels[1].ChannelUID {\n\t\tt.Fatalf(\"请求协议应作为主路由: response=%+v\", response)\n\t}\n\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 || len(cfg.ChatUpstream) != 1 || len(cfg.GeminiUpstream) != 0 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d chat=%d gemini=%d\",\n\t\t\tlen(cfg.Upstream), len(cfg.ResponsesUpstream), len(cfg.ChatUpstream), len(cfg.GeminiUpstream))\n\t}\n\tchecks := []struct {\n\t\tchannel config.UpstreamConfig\n\t\tname string\n\t\tmodels []string\n\t}{\n\t\t{cfg.Upstream[0], \"fastaitoken-com-test-claude\", []string{\"shared\", \"messages-only\"}},\n\t\t{cfg.ResponsesUpstream[0], \"fastaitoken-com-test-codex\", []string{\"shared\", \"responses-only\"}},\n\t\t{cfg.ChatUpstream[0], \"fastaitoken-com-test-chat\", []string{\"shared\", \"chat-only\"}},\n\t}\n\tfor _, check := range checks {\n\t\tif check.channel.Name != check.name || check.channel.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route identity=%+v, want name=%q account=%q\", check.channel, check.name, response.AccountUID)\n\t\t}\n\t\tif strings.Join(check.channel.SupportedModels, \",\") != strings.Join(check.models, \",\") {\n\t\t\tt.Fatalf(\"route %s models=%v, want %v\", check.name, check.channel.SupportedModels, check.models)\n\t\t}\n\t}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesRenamesAndSyncsCredentials(t *testing.T) {\n\tchannels := []config.AccountChannel{\n\t\t{Kind: \"messages\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-messages\", Name: \"old-claude\",\n\t\t\tServiceType: \"claude\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t\t{Kind: \"responses\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-responses\", Name: \"old-codex\",\n\t\t\tServiceType: \"responses\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t}\n\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\tName: \"renamed\", APIKeys: []string{\"sk-old\", \"sk-new\"},\n\t}, channels, len(channels))\n\tif err != nil || status != http.StatusOK {\n\t\tt.Fatalf(\"status=%d err=%v\", status, err)\n\t}\n\tif len(updates) != 2 || updates[0].Name != \"renamed-claude\" || updates[1].Name != \"renamed-codex\" {\n\t\tt.Fatalf(\"route names=%+v\", updates)\n\t}\n\tfor _, update := range updates {\n\t\tif len(update.APIKeyConfig) != 2 || update.APIKeyConfig[1].BaseURL != \"https://example.com\" {\n\t\t\tt.Fatalf(\"route credentials=%+v\", update.APIKeyConfig)\n\t\t}\n\t\tif update.APIKeyConfig[1].CredentialUID == \"\" {\n\t\t\tt.Fatalf(\"new credential missing stable uid: %+v\", update.APIKeyConfig[1])\n\t\t}\n\t}\n}\n\n// newCustomManagedChannel 构造一条自定义自动托管渠道,用于地址池编辑链路的表驱动断言。\nfunc newCustomManagedChannel(kind, serviceType string, baseURLs []string, keyConfigs []config.APIKeyConfig) config.AccountChannel {\n\tapiKeys := make([]string, 0, len(keyConfigs))\n\tfor _, keyConfig := range keyConfigs {\n\t\tapiKeys = append(apiKeys, keyConfig.Key)\n\t}\n\tprimary := \"\"\n\tif len(baseURLs) > 0 {\n\t\tprimary = baseURLs[0]\n\t}\n\treturn config.AccountChannel{Kind: kind, Upstream: config.UpstreamConfig{\n\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-\" + kind, Name: \"custom\" + accountRouteSuffix(kind),\n\t\tServiceType: serviceType, AutoManaged: true, BaseURL: primary,\n\t\tBaseURLs: append([]string(nil), baseURLs...), APIKeys: apiKeys,\n\t\tAPIKeyConfigs: append([]config.APIKeyConfig(nil), keyConfigs...),\n\t}}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesBaseURLPool(t *testing.T) {\n\tconst (\n\t\turlA = \"https://a.example.com\"\n\t\turlB = \"https://b.example.com\"\n\t\turlC = \"https://c.example.com\"\n\t)\n\ttests := []struct {\n\t\tname string\n\t\t// existingURLs / existingKeys 描述渠道现状,requestURLs 为请求里的 baseUrls(nil 表示未提供)\n\t\texistingURLs []string\n\t\texistingKeys []config.APIKeyConfig\n\t\tapiKeys []string\n\t\trequestURLs []string\n\t\twantStatus int\n\t\twantErr bool\n\t\twantBaseURLs []string\n\t\t// wantBindings 与 apiKeys 顺序一致,空串表示该 Key 未绑定端点\n\t\twantBindings []string\n\t}{\n\t\t{\n\t\t\tname: \"单地址扩为两地址\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA, urlB},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlB},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"两地址删除其中一个且保留有效绑定\",\n\t\t\texistingURLs: []string{urlA, urlB},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"地址顺序变化改变首地址\",\n\t\t\texistingURLs: []string{urlA, urlB},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlB, urlA},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlB, urlA},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"尾部斜杠与默认版本前缀等价地址去重\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA, urlA + \"/\", urlA + \"/v1\", \" \" + urlB + \"/ \", urlB},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlB},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"全部非法地址被拒绝\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{\"\", \" \", \"/\"},\n\t\t\twantStatus: http.StatusBadRequest,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"未提供地址时沿用渠道现有地址\",\n\t\t\texistingURLs: []string{urlA, urlB},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlB}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: nil,\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlB},\n\t\t\twantBindings: []string{urlB},\n\t\t},\n\t\t{\n\t\t\tname: \"绑定指向已删除地址时迁移到新首地址\",\n\t\t\texistingURLs: []string{urlA, urlB},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlB}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA, urlC},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlC},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"原本未绑定的凭证保持未绑定\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\"}, {Key: \"sk-2\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\", \"sk-2\", \"sk-new\"},\n\t\t\trequestURLs: []string{urlB, urlA},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlB, urlA},\n\t\t\t// sk-1 未绑定保持为空;sk-2 绑定仍在池内保持不变;新增 sk-new 绑定首地址\n\t\t\twantBindings: []string{\"\", urlA, urlB},\n\t\t},\n\t\t{\n\t\t\tname: \"非规范绑定被折叠为 canonical 形式\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA + \"/v1/\"}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA, urlB},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlB},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tchannels := []config.AccountChannel{\n\t\t\t\tnewCustomManagedChannel(\"messages\", \"claude\", tt.existingURLs, tt.existingKeys),\n\t\t\t}\n\t\t\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\t\t\tName: \"custom\", APIKeys: tt.apiKeys, BaseURLs: tt.requestURLs,\n\t\t\t}, channels, len(channels))\n\t\t\tif status != tt.wantStatus || (err != nil) != tt.wantErr {\n\t\t\t\tt.Fatalf(\"status=%d err=%v, want status=%d err=%v\", status, err, tt.wantStatus, tt.wantErr)\n\t\t\t}\n\t\t\tif tt.wantErr {\n\t\t\t\tif len(updates) != 0 {\n\t\t\t\t\tt.Fatalf(\"非法输入不应产生渠道更新: %+v\", updates)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(updates) != 1 {\n\t\t\t\tt.Fatalf(\"updates=%+v, want 1\", updates)\n\t\t\t}\n\t\t\tupdate := updates[0]\n\t\t\tif strings.Join(update.BaseURLs, \",\") != strings.Join(tt.wantBaseURLs, \",\") {\n\t\t\t\tt.Fatalf(\"BaseURLs=%v, want %v\", update.BaseURLs, tt.wantBaseURLs)\n\t\t\t}\n\t\t\tif len(update.APIKeyConfig) != len(tt.wantBindings) {\n\t\t\t\tt.Fatalf(\"APIKeyConfig=%+v, want %d 条\", update.APIKeyConfig, len(tt.wantBindings))\n\t\t\t}\n\t\t\tfor i, keyConfig := range update.APIKeyConfig {\n\t\t\t\tif keyConfig.Key != tt.apiKeys[i] {\n\t\t\t\t\tt.Fatalf(\"APIKeyConfig[%d].Key=%q, want %q\", i, keyConfig.Key, tt.apiKeys[i])\n\t\t\t\t}\n\t\t\t\tif keyConfig.BaseURL != tt.wantBindings[i] {\n\t\t\t\t\tt.Fatalf(\"APIKeyConfig[%d](%s).BaseURL=%q, want %q\", i, keyConfig.Key, keyConfig.BaseURL, tt.wantBindings[i])\n\t\t\t\t}\n\t\t\t\tif keyConfig.CredentialUID == \"\" {\n\t\t\t\t\tt.Fatalf(\"APIKeyConfig[%d] 缺少稳定 credentialUid: %+v\", i, keyConfig)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesSyncsBaseURLPoolAcrossProtocols(t *testing.T) {\n\tconst (\n\t\turlA = \"https://a.example.com\"\n\t\turlB = \"https://b.example.com\"\n\t)\n\texistingKeys := []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}}\n\tchannels := []config.AccountChannel{\n\t\tnewCustomManagedChannel(\"messages\", \"claude\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"chat\", \"openai\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"responses\", \"responses\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"gemini\", \"gemini\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"images\", \"openai\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"vectors\", \"openai\", []string{urlA}, existingKeys),\n\t}\n\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\tName: \"custom\", APIKeys: []string{\"sk-1\"}, BaseURLs: []string{urlB + \"/\", urlA},\n\t}, channels, len(channels))\n\tif err != nil || status != http.StatusOK {\n\t\tt.Fatalf(\"status=%d err=%v\", status, err)\n\t}\n\tif len(updates) != len(channels) {\n\t\tt.Fatalf(\"updates=%d, want %d\", len(updates), len(channels))\n\t}\n\twantPool := urlB + \",\" + urlA\n\tfor i, update := range updates {\n\t\tif got := strings.Join(update.BaseURLs, \",\"); got != wantPool {\n\t\t\tt.Fatalf(\"%s 渠道地址池=%q, want %q\", channels[i].Kind, got, wantPool)\n\t\t}\n\t\tif len(update.APIKeyConfig) != 1 || update.APIKeyConfig[0].BaseURL != urlA {\n\t\t\tt.Fatalf(\"%s 渠道凭证绑定=%+v, want %s\", channels[i].Kind, update.APIKeyConfig, urlA)\n\t\t}\n\t}\n}\n\nfunc TestUpdateAccountIgnoresBaseURLsForProviderManagedAccount(t *testing.T) {\n\tconst templateBaseURL = \"https://api.xiaomimimo.com/anthropic\"\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_provider\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_provider\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"` + templateBaseURL + `\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"` + templateBaseURL + `\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\tbody := `{\"name\":\"mimo-main\",\"apiKeys\":[\"sk-existing\"],\"baseUrls\":[\"https://manual-pool.example.com\"]}`\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_provider\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PUT /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_provider\")\n\tif len(channels) == 0 {\n\t\tt.Fatalf(\"provider 托管账号渠道丢失\")\n\t}\n\tfor _, accountChannel := range channels {\n\t\tchannel := accountChannel.Upstream\n\t\tif channel.BaseURL != templateBaseURL {\n\t\t\tt.Fatalf(\"%s 渠道主地址被手工地址覆盖: %q\", accountChannel.Kind, channel.BaseURL)\n\t\t}\n\t\tfor _, baseURL := range channel.GetAllBaseURLs() {\n\t\t\tif strings.Contains(baseURL, \"manual-pool\") {\n\t\t\t\tt.Fatalf(\"%s 渠道地址池混入手工地址: %v\", accountChannel.Kind, channel.GetAllBaseURLs())\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestProviderRouteNameAndPrimaryResult(t *testing.T) {\n\tbase := \"mimo-test\"\n\ttests := []struct {\n\t\troute config.ProviderRoute\n\t\twant string\n\t}{\n\t\t{route: config.ProviderRoute{ChannelKind: \"messages\"}, want: \"mimo-test-claude\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"chat\"}, want: \"mimo-test-chat\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"responses\"}, want: \"mimo-test-codex\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"gemini\"}, want: \"mimo-test-gemini\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := providerRouteName(base, tt.route, true); got != tt.want {\n\t\t\tt.Fatalf(\"providerRouteName(%s)=%q, want %q\", tt.route.ChannelKind, got, tt.want)\n\t\t}\n\t}\n\tif got := providerRouteName(base, config.ProviderRoute{ChannelKind: \"messages\"}, false); got != base {\n\t\tt.Fatalf(\"single route name=%q, want %q\", got, base)\n\t}\n\n\tresults := []AutoAddChannelResult{\n\t\t{ChannelKind: \"messages\", ChannelUID: \"ch_messages\", Index: 1},\n\t\t{ChannelKind: \"chat\", ChannelUID: \"ch_chat\", Index: 2},\n\t\t{ChannelKind: \"responses\", ChannelUID: \"ch_responses\", Index: 3},\n\t}\n\tprimary := primaryAutoAddResult(results, \"chat\")\n\tif primary.ChannelUID != \"ch_chat\" || primary.Index != 2 {\n\t\tt.Fatalf(\"primary=%+v, want chat result\", primary)\n\t}\n}\n\nfunc TestManagedAccountUIDForProviderUsesExistingAccount(t *testing.T) {\n\tcfg := config.Config{ManagedAccounts: []config.ManagedAccountConfig{\n\t\t{AccountUID: \"acct-old\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-new\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-deepseek\", ProviderID: \"deepseek\"},\n\t}}\n\tif got := managedAccountUIDForProvider(cfg, \"mimo\"); got != \"acct-new\" {\n\t\tt.Fatalf(\"mimo existing account = %q, want acct-new\", got)\n\t}\n\tif got := managedAccountUIDForProvider(cfg, \"unknown\"); got != \"\" {\n\t\tt.Fatalf(\"unknown provider 不应命中账号: %q\", got)\n\t}\n}\n\nfunc TestListManagedAccountsDoesNotExposeVolcengineSecret(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-secret\",\"volcengineAccessKey\":{\"accessKeyId\":\"AKIDEXAMPLE\",\"secretAccessKey\":\"SECRET_MUST_NOT_LEAK\",\"plan\":\"agent_plan\",\"planTier\":\"Large\",\"planStatus\":\"Running\"}}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"SECRET_MUST_NOT_LEAK\") || strings.Contains(body, \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", body)\n\t}\n\tfor _, expected := range []string{`\"hasVolcengineAccessKey\":true`, `\"volcenginePlan\":\"agent_plan\"`, `\"volcenginePlanTier\":\"Large\"`} {\n\t\tif !strings.Contains(body, expected) {\n\t\t\tt.Fatalf(\"响应缺少 %s: %s\", expected, body)\n\t\t}\n\t}\n}\n\nfunc TestKimiConsoleTokenLifecycleDoesNotExposeToken(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_kimi\",\"providerId\":\"kimi\",\"name\":\"kimi\",\"credentials\":[{\"credentialUid\":\"cred_kimi\",\"apiKey\":\"sk-kimi-secret\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_kimi\",\"channelUid\":\"ch_kimi\",\"providerId\":\"kimi\",\"name\":\"kimi\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.kimi.com/coding\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_kimi\",\"baseUrl\":\"https://api.kimi.com/coding\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tkimiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Authorization\") != \"Bearer web-session-secret\" {\n\t\t\tt.Fatalf(\"Kimi 请求未使用保存的会话令牌\")\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase kimiUsagesPath:\n\t\t\t_, _ = w.Write([]byte(`{\"usages\":[{\"scope\":\"FEATURE_CODING\",\"detail\":{\"limit\":\"100\",\"remaining\":\"80\",\"resetTime\":\"2026-07-27T16:13:43Z\"},\"limits\":[{\"window\":{\"duration\":300,\"timeUnit\":\"TIME_UNIT_MINUTE\"},\"detail\":{\"limit\":\"100\",\"remaining\":\"90\",\"resetTime\":\"2026-07-21T02:13:43Z\"}}]}],\"totalQuota\":{\"limit\":\"100\",\"remaining\":\"80\"}}`))\n\t\tcase kimiSubscriptionStatsPath:\n\t\t\t_, _ = w.Write([]byte(`{\"ratelimitCode5h\":{\"ratio\":0.1,\"enabled\":true},\"ratelimitCode7d\":{\"ratio\":0.2,\"enabled\":true},\"subscriptionBalance\":{\"feature\":\"FEATURE_OMNI\",\"type\":\"SUBSCRIPTION\",\"unit\":\"UNIT_CREDIT\",\"amountUsedRatio\":0.2,\"kimiCodeUsedRatio\":0.2,\"expireTime\":\"2026-08-20T16:14:07Z\"}}`))\n\t\tdefault:\n\t\t\thttp.NotFound(w, r)\n\t\t}\n\t}))\n\tdefer kimiServer.Close()\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: cfgManager,\n\t\tKimiConsoleClient: &KimiConsoleClient{\n\t\t\tHTTPClient: kimiServer.Client(), BaseURL: kimiServer.URL,\n\t\t},\n\t})\n\tput := httptest.NewRequest(\n\t\thttp.MethodPut,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token\",\n\t\tbytes.NewBufferString(`{\"accessToken\":\"Bearer web-session-secret\"}`),\n\t)\n\tput.Header.Set(\"Content-Type\", \"application/json\")\n\tputRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(putRecorder, put)\n\tif putRecorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"绑定 Kimi 令牌失败: status=%d body=%s\", putRecorder.Code, putRecorder.Body.String())\n\t}\n\tif strings.Contains(putRecorder.Body.String(), \"web-session-secret\") || strings.Contains(putRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"绑定响应泄露 Kimi 令牌: %s\", putRecorder.Body.String())\n\t}\n\tcredential, ok := cfgManager.GetManagedAccountCredential(\"acct_kimi\", \"cred_kimi\")\n\tif !ok || credential.KimiConsole == nil || credential.KimiConsole.AccessToken != \"web-session-secret\" {\n\t\tt.Fatalf(\"Kimi 令牌未持久化: %+v\", credential)\n\t}\n\tpersisted, err := os.ReadFile(configPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !strings.Contains(string(persisted), `\"accessToken\": \"web-session-secret\"`) {\n\t\tt.Fatalf(\"配置文件未保存 Kimi 令牌\")\n\t}\n\n\tlistRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(listRecorder, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif listRecorder.Code != http.StatusOK || strings.Contains(listRecorder.Body.String(), \"web-session-secret\") || strings.Contains(listRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"账号列表响应异常或泄露令牌: status=%d body=%s\", listRecorder.Code, listRecorder.Body.String())\n\t}\n\tif !strings.Contains(listRecorder.Body.String(), `\"hasKimiConsoleToken\":true`) || !strings.Contains(listRecorder.Body.String(), `\"kimiCodeUsage\"`) {\n\t\tt.Fatalf(\"账号列表缺少 Kimi 用量快照: %s\", listRecorder.Body.String())\n\t}\n\n\trefreshRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(refreshRecorder, httptest.NewRequest(\n\t\thttp.MethodPost,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token/refresh\",\n\t\tnil,\n\t))\n\tif refreshRecorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"刷新 Kimi 余量失败: status=%d body=%s\", refreshRecorder.Code, refreshRecorder.Body.String())\n\t}\n\tif strings.Contains(refreshRecorder.Body.String(), \"web-session-secret\") || strings.Contains(refreshRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"刷新响应泄露 Kimi 令牌: %s\", refreshRecorder.Body.String())\n\t}\n\n\tdeleteRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(deleteRecorder, httptest.NewRequest(\n\t\thttp.MethodDelete,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token\",\n\t\tnil,\n\t))\n\tif deleteRecorder.Code != http.StatusNoContent {\n\t\tt.Fatalf(\"解绑 Kimi 令牌失败: status=%d body=%s\", deleteRecorder.Code, deleteRecorder.Body.String())\n\t}\n\tcredential, _ = cfgManager.GetManagedAccountCredential(\"acct_kimi\", \"cred_kimi\")\n\tif credential.KimiConsole != nil {\n\t\tt.Fatalf(\"Kimi 令牌解绑后仍存在: %+v\", credential.KimiConsole)\n\t}\n\tpersisted, err = os.ReadFile(configPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(persisted), \"web-session-secret\") {\n\t\tt.Fatalf(\"解绑后配置文件仍包含 Kimi 令牌\")\n\t}\n}\n\nfunc TestSetVolcengineAccessKeyDetectsPlanBeforePersisting(t *testing.T) {\n\tcontrolPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Query().Get(\"Action\") != \"GetPersonalPlan\" {\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tvar body struct{ Plan string }\n\t\t_ = json.NewDecoder(r.Body).Decode(&body)\n\t\tif body.Plan == \"CodingPlan\" {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t_, _ = w.Write([]byte(`{\"ResponseMetadata\":{\"Error\":{\"Code\":\"ResourceNotFound.Plan\",\"Message\":\"not found\"}}}`))\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\"Result\":{\"PlanType\":\"Large\",\"Status\":\"Running\"}}`))\n\t}))\n\tdefer controlPlane.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-inference\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trunner := NewAutoDiscoveryRunner(nil, nil)\n\trunner.client = controlPlane.Client()\n\trunner.volcengineControlPlaneEndpoint = controlPlane.URL\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, Runner: runner})\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_volc/credentials/cred_volc/volcengine-access-key\", bytes.NewBufferString(`{\"accessKeyId\":\"AKID\",\"secretAccessKey\":\"SECRET\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"plan\":\"agent_plan\"`) {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_volc\", \"cred_volc\")\n\tif !ok || credential.VolcengineAccessKey == nil || credential.VolcengineAccessKey.SecretAccessKey != \"SECRET\" || credential.VolcengineAccessKey.Plan != \"agent_plan\" {\n\t\tt.Fatalf(\"凭证未正确持久化: %+v\", credential)\n\t}\n}\n\nfunc TestSetMiMoConsoleCookieRequiresConfirmationBeforeAdoptingKey(t *testing.T) {\n\tconst oldKey = \"tp-old-1234567890123456789012345678901234567890\"\n\tconst cookieKey = \"tp-new-1234567890123456789012345678901234567890\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != \"api-platform_serviceToken=session; userId=42\" {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase \"/api/v1/userProfile\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"userId\":\"42\"}}`))\n\t\tcase \"/api/v1/tokenPlan/detail\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"planCode\":\"max\",\"planName\":\"Max\",\"currentPeriodEnd\":\"2026-07-29 23:59:59\"}}`))\n\t\tcase \"/api/v1/tokenPlan/usage\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"monthUsage\":{\"percent\":0.25,\"items\":[{\"used\":25,\"limit\":100}]},\"usage\":{\"percent\":0.5,\"items\":[{\"used\":50,\"limit\":100}]}}}`))\n\t\tcase \"/api/v1/tokenPlan/apiKey/raw\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":\"` + cookieKey + `\"}`))\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}\n\t}))\n\tdefer console.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"credentials\":[{\"credentialUid\":\"cred_mimo\",\"apiKey\":\"` + oldKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_mimo\",\"channelUid\":\"ch_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_mimo\",\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\tdeps := &AutoManagedDeps{CfgManager: manager, MiMoConsoleClient: &MiMoConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL}}\n\trouter := setupAutoManagedRouter(deps)\n\tbody := `{\"cookie\":\"api-platform_serviceToken=session; userId=42\"}`\n\trequest := func(payload string) *httptest.ResponseRecorder {\n\t\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_mimo/credentials/cred_mimo/mimo-console-cookie\", bytes.NewBufferString(payload))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tw := httptest.NewRecorder()\n\t\trouter.ServeHTTP(w, req)\n\t\treturn w\n\t}\n\tw := request(body)\n\tif w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), `\"code\":\"mimo_cookie_key_mismatch\"`) {\n\t\tt.Fatalf(\"未要求确认: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ := manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != oldKey || credential.MiMoConsole != nil {\n\t\tt.Fatalf(\"确认前不应修改配置: %+v\", credential)\n\t}\n\tw = request(`{\"cookie\":\"api-platform_serviceToken=session; userId=42\",\"adoptCookieKey\":true}`)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"keyAdopted\":true`) {\n\t\tt.Fatalf(\"确认采用失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ = manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != cookieKey || credential.MiMoConsole == nil {\n\t\tt.Fatalf(\"确认后未采用 Cookie Key: %+v\", credential)\n\t}\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif strings.Contains(list.Body.String(), \"api-platform_serviceToken\") || strings.Contains(list.Body.String(), cookieKey) {\n\t\tt.Fatalf(\"账号列表泄漏 Cookie 或原始 Key: %s\", list.Body.String())\n\t}\n}\n\nfunc TestCompshareConsoleCookieBindingDoesNotExposeSecrets(t *testing.T) {\n\tconst currentKey = \"sk-cp-test-current\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != testCompshareCookie {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\n \"RetCode\": 0,\n \"UserPlans\": [{\n \"Code\": \"cppkg-test\", \"PlanCode\": \"cp-plan-test\", \"PlanName\": \"Pro\", \"DisplayName\": \"Pro 增强版\",\n \"LimitPer5h\": 3000, \"LimitPerWeek\": 7500, \"LimitPerMonth\": 19000,\n \"UsagePer5h\": 0, \"UsagePerWeek\": 100, \"UsagePerMonth\": 6496,\n \"ConcurrencyLimit\": 10, \"Status\": 1, \"ExpireAt\": 1785037981,\n \"Keys\": [{\"APIKey\": \"` + currentKey + `\"}]\n }]\n}`))\n\t}))\n\tdefer console.Close()\n\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_compshare\",\"providerId\":\"compshare\",\"name\":\"compshare\",\"credentials\":[{\"credentialUid\":\"cred_compshare\",\"apiKey\":\"` + currentKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_compshare\",\"channelUid\":\"ch_compshare\",\"providerId\":\"compshare\",\"name\":\"compshare\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://api.compshare.cn/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_compshare\",\"baseUrl\":\"https://api.compshare.cn/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: manager,\n\t\tCompshareConsoleClient: &CompshareConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL},\n\t})\n\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_compshare/credentials/cred_compshare/compshare-console-cookie\", bytes.NewBufferString(`{\"cookie\":\"`+testCompshareCookie+`\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"displayName\":\"Pro 增强版\"`) {\n\t\tt.Fatalf(\"绑定失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_compshare\", \"cred_compshare\")\n\tif !ok || credential.CompshareConsole == nil || credential.CompshareConsole.Cookie != testCompshareCookie {\n\t\tt.Fatalf(\"控制台套餐未持久化: %+v\", credential)\n\t}\n\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tresponse := list.Body.String()\n\tfor _, secret := range []string{\"U_JWT_TOKEN\", \"test-session\", \"test_user@console.compshare.cn\", \"org-test\", currentKey, \"cppkg-test\"} {\n\t\tif strings.Contains(response, secret) {\n\t\t\tt.Fatalf(\"账号列表泄漏敏感字段 %q: %s\", secret, response)\n\t\t}\n\t}\n\tfor _, expected := range []string{`\"hasCompshareConsoleCookie\":true`, `\"planCode\":\"cp-plan-test\"`, `\"monthlyUsage\":{\"used\":6496,\"limit\":19000`} {\n\t\tif !strings.Contains(response, expected) {\n\t\t\tt.Fatalf(\"账号列表缺少套餐字段 %s: %s\", expected, response)\n\t\t}\n\t}\n}\n\nfunc TestProviderAutoAddReusesExistingAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(`{\"providerId\":\"mimo\",\"apiKeys\":[\"sk-existing\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"追加已有 provider key status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 4 {\n\t\tt.Fatalf(\"旧账号应自动补齐模板中的协议渠道: %+v\", channels)\n\t}\n\tkinds := make(map[string]bool, len(channels))\n\tfor _, channel := range channels {\n\t\tkinds[channel.Kind] = true\n\t}\n\tfor _, kind := range []string{\"messages\", \"chat\", \"responses\", \"gemini\"} {\n\t\tif !kinds[kind] {\n\t\t\tt.Fatalf(\"旧账号缺少 %s route: %+v\", kind, channels)\n\t\t}\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.AccountUID != \"acct_test\" {\n\t\tt.Fatalf(\"响应未返回已有账号: body=%s err=%v\", w.Body.String(), err)\n\t}\n\tif len(response.Channels) != 4 {\n\t\tt.Fatalf(\"响应未返回补齐后的全部渠道: %+v\", response.Channels)\n\t}\n}\n\nfunc TestMissingProviderAccountRoutes(t *testing.T) {\n\ttmpl, ok := config.GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"缺少 deepseek provider 模板\")\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{ServiceType: \"claude\"},\n\t}}\n\tmissing := missingProviderAccountRoutes(tmpl, existing)\n\tif len(missing) != 2 || missing[0].ChannelKind != \"chat\" || missing[1].ChannelKind != \"responses\" {\n\t\tt.Fatalf(\"missing routes = %+v\", missing)\n\t}\n\tconfigs, baseURLs, err := bindProviderRouteKeys(tmpl, missing[0], []string{\"sk-existing\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(configs) != 1 || configs[0].BaseURL != \"https://api.deepseek.com\" || len(baseURLs) != 1 {\n\t\tt.Fatalf(\"route 绑定结果不正确: configs=%+v baseURLs=%v\", configs, baseURLs)\n\t}\n}\n\nfunc TestBindProviderRouteKeysPreservesExistingEndpointAffinity(t *testing.T) {\n\ttests := []struct {\n\t\tproviderID string\n\t\tapiKey string\n\t\texistingBaseURL string\n\t\ttargetKind string\n\t\twantBaseURL string\n\t}{\n\t\t{\n\t\t\tproviderID: \"mimo\", apiKey: \"tp-existing\",\n\t\t\texistingBaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\t\ttargetKind: \"chat\", wantBaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\",\n\t\t},\n\t\t{\n\t\t\tproviderID: \"volcengine\", apiKey: \"ark-existing\",\n\t\t\texistingBaseURL: \"https://ark.cn-beijing.volces.com/api/coding\",\n\t\t\ttargetKind: \"responses\", wantBaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.providerID, func(t *testing.T) {\n\t\t\ttmpl, ok := config.GetProviderTemplate(tt.providerID)\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"缺少 provider 模板: %s\", tt.providerID)\n\t\t\t}\n\t\t\texisting := []config.AccountChannel{{\n\t\t\t\tKind: \"messages\",\n\t\t\t\tUpstream: config.UpstreamConfig{\n\t\t\t\t\tProviderID: tt.providerID, ServiceType: \"claude\", APIKeys: []string{tt.apiKey},\n\t\t\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: tt.apiKey, BaseURL: tt.existingBaseURL}},\n\t\t\t\t},\n\t\t\t}}\n\t\t\tvar target config.ProviderRoute\n\t\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\t\tif route.ChannelKind == tt.targetKind {\n\t\t\t\t\ttarget = route\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tconfigs, _, err := bindProviderRouteKeysWithAffinities(\n\t\t\t\ttmpl, target, []string{tt.apiKey}, providerKeyCandidateAffinities(tmpl, existing),\n\t\t\t)\n\t\t\tif err != nil || len(configs) != 1 || configs[0].BaseURL != tt.wantBaseURL {\n\t\t\t\tt.Fatalf(\"亲和绑定错误: configs=%+v err=%v want=%s\", configs, err, tt.wantBaseURL)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPlanProviderAccountRouteAdditionsRejectsNewKeyBeforeMutation(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t}))\n\tdefer server.Close()\n\n\ttmpl := &config.ProviderTemplate{\n\t\tProviderID: \"test-provider\",\n\t\tRoutes: []config.ProviderRoute{\n\t\t\t{ChannelKind: \"messages\", ServiceType: \"claude\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/anthropic\"}}},\n\t\t\t{ChannelKind: \"chat\", ServiceType: \"openai\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/v1\"}}},\n\t\t},\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct_test\", ChannelUID: \"ch_messages\", Name: \"test-provider\",\n\t\t\tProviderID: \"test-provider\", ServiceType: \"claude\", AutoManaged: true, APIKeys: []string{\"sk-existing\"},\n\t\t},\n\t}}\n\tadditions, status, err := planProviderAccountRouteAdditions(\n\t\tt.Context(), config.Config{}, tmpl, \"acct_test\", []string{\"sk-existing\", \"sk-invalid\"}, existing,\n\t)\n\tif err == nil || status != http.StatusBadRequest || len(additions) != 0 {\n\t\tt.Fatalf(\"无效新 Key 应在生成新增 route 前失败: status=%d additions=%+v err=%v\", status, additions, err)\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}, "d971de1e20ffe917724faa91e964fed6adeb6a49": {"t": 1785759333.2698066, "n": 59185, "h": "b649ce394aa0a882", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/BenedictKing/ccx/internal/errutil\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestListAccountsMasksCredentials(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-secret-value\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil)\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"sk-secret-value\") {\n\t\tt.Fatalf(\"账号列表泄露明文 Key: %s\", body)\n\t}\n\tif !strings.Contains(body, \"cred_test\") || !strings.Contains(body, \"keyMask\") {\n\t\tt.Fatalf(\"账号列表缺少凭证掩码信息: %s\", body)\n\t}\n}\n\nfunc TestListAccountsIncludesActiveEndpointModelAvailability(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"volcengine\",\"name\":\"volcengine\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"ark-secret-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"ark-secret-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"volcengine\",\"name\":\"volcengine-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://example.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tstore, err := NewProfileStoreWithDB(newTestDB(t))\n\tif err != nil {\n\t\tt.Fatalf(\"NewProfileStoreWithDB 失败: %v\", err)\n\t}\n\tupdatedAt := time.Date(2026, 7, 17, 10, 54, 33, 0, time.UTC)\n\tdiscoveredAt := updatedAt\n\tdiscoveredAtLater := updatedAt.Add(time.Minute)\n\tfor _, profile := range []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_a\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_a\", KeyMask: \"ark-a***001\", AvailableModels: []string{\"model-b\", \"model-a\"}, UpdatedAt: updatedAt,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI, ModelsDiscoveredAt: &discoveredAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_b\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_b\", KeyMask: \"ark-b***002\", AvailableModels: []string{\"model-c\", \"model-b\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI, ModelsDiscoveredAt: &discoveredAtLater,\n\t\t},\n\t} {\n\t\tif err := store.Upsert(profile); err != nil {\n\t\t\tt.Fatalf(\"ProfileStore.Upsert 失败: %v\", err)\n\t\t}\n\t}\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager, Runner: runner})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response struct {\n\t\tAccounts []managedAccountView `json:\"accounts\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatalf(\"解析响应失败: %v\", err)\n\t}\n\tif len(response.Accounts) != 1 || len(response.Accounts[0].Channels) != 1 {\n\t\tt.Fatalf(\"账号渠道数量错误: %+v\", response.Accounts)\n\t}\n\tchannel := response.Accounts[0].Channels[0]\n\tif got, want := strings.Join(channel.DiscoveredModels, \",\"), \"model-a,model-b,model-c\"; got != want {\n\t\tt.Fatalf(\"DiscoveredModels=%q, want %q\", got, want)\n\t}\n\tif !channel.ModelInventoryKnown {\n\t\tt.Fatal(\"已发现模型时 ModelInventoryKnown 应为 true\")\n\t}\n\tif len(channel.ModelBindings) != 2 {\n\t\tt.Fatalf(\"ModelBindings=%+v, want 2\", channel.ModelBindings)\n\t}\n\tif got := strings.Join(channel.ModelBindings[0].Models, \",\"); got != \"model-a,model-b\" {\n\t\tt.Fatalf(\"第一个 Key 模型=%q\", got)\n\t}\n\tif channel.ModelsUpdatedAt == nil || !channel.ModelsUpdatedAt.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"ModelsUpdatedAt=%v\", channel.ModelsUpdatedAt)\n\t}\n\tif strings.Contains(w.Body.String(), \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", w.Body.String())\n\t}\n}\n\nfunc TestListAccountsIncludesCustomAutoManagedProtocolAvailability(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [],\n \"upstream\": [], \"chatUpstream\": [],\n \"responsesUpstream\": [{\"accountUid\":\"acct_custom\",\"channelUid\":\"ch_custom\",\"name\":\"custom-endpoint\",\"serviceType\":\"responses\",\"baseUrl\":\"https://example.com\",\"apiKeys\":[\"sk-custom-secret\"],\"apiKeyConfigs\":[{\"key\":\"sk-custom-secret\",\"credentialUid\":\"cred_custom\",\"baseUrl\":\"https://example.com\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tstore, err := NewProfileStoreWithDB(newTestDB(t))\n\tif err != nil {\n\t\tt.Fatalf(\"NewProfileStoreWithDB 失败: %v\", err)\n\t}\n\tdiscoveredAt := time.Date(2026, 7, 25, 5, 11, 29, 0, time.UTC)\n\tprofile := &KeyEndpointProfile{\n\t\tEndpointUID: \"ep_custom\", AccountUID: \"acct_custom\", ChannelUID: \"ch_custom\",\n\t\tCredentialUID: \"cred_custom\", KeyMask: \"sk-c***ret\", ServiceType: \"responses\",\n\t\tAvailableModels: []string{\"gpt-5.6-sol\"}, ModelsDiscoveredAt: &discoveredAt,\n\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI,\n\t\tProtocolModels: map[string][]string{\n\t\t\t\"messages\": {\"gpt-5.6-sol\"},\n\t\t\t\"chat\": {\"gpt-5.6-sol\"},\n\t\t\t\"responses\": {\"gpt-5.6-sol\"},\n\t\t},\n\t\tProtocolDiscoveredAt: map[string]time.Time{\n\t\t\t\"messages\": discoveredAt, \"chat\": discoveredAt, \"responses\": discoveredAt,\n\t\t},\n\t\tProtocolDiscoverySource: map[string]string{\n\t\t\t\"messages\": \"protocol_probe\", \"chat\": \"protocol_probe\", \"responses\": \"models_api\",\n\t\t},\n\t\tUpdatedAt: discoveredAt,\n\t}\n\tif err := store.Upsert(profile); err != nil {\n\t\tt.Fatalf(\"ProfileStore.Upsert 失败: %v\", err)\n\t}\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: cfgManager,\n\t\tRunner: NewAutoDiscoveryRunner(store, nil),\n\t})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tif strings.Contains(w.Body.String(), \"sk-custom-secret\") {\n\t\tt.Fatalf(\"账号列表泄露自定义渠道明文 Key: %s\", w.Body.String())\n\t}\n\n\tvar response struct {\n\t\tAccounts []managedAccountView `json:\"accounts\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatalf(\"解析响应失败: %v\", err)\n\t}\n\tif len(response.Accounts) != 1 {\n\t\tt.Fatalf(\"Accounts=%+v, want 1\", response.Accounts)\n\t}\n\taccount := response.Accounts[0]\n\tif account.AccountUID != \"acct_custom\" || len(account.Credentials) != 1 || len(account.Channels) != 1 {\n\t\tt.Fatalf(\"自定义自动托管账号聚合错误: %+v\", account)\n\t}\n\tprotocols := make([]string, 0, len(account.Channels[0].ProtocolAvailability))\n\tfor _, availability := range account.Channels[0].ProtocolAvailability {\n\t\tprotocols = append(protocols, availability.Protocol)\n\t}\n\tif got, want := strings.Join(protocols, \",\"), \"messages,chat,responses\"; got != want {\n\t\tt.Fatalf(\"ProtocolAvailability=%q, want %q\", got, want)\n\t}\n}\n\nfunc TestManagedChannelModelAvailabilityIncludesEmptyAutoDiscoveryResult(t *testing.T) {\n\tupdatedAt := time.Date(2026, 7, 17, 11, 2, 0, 0, time.UTC)\n\tmodels, bindings, latest, known := managedChannelModelAvailability([]*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_empty\", CredentialUID: \"cred_empty\", KeyMask: \"ark-e***001\",\n\t\t\tSource: \"auto_discovery\", AvailableModels: []string{}, UpdatedAt: updatedAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_models\", CredentialUID: \"cred_models\", KeyMask: \"ark-m***002\",\n\t\t\tAvailableModels: []string{\"model-a\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_unknown\", CredentialUID: \"cred_unknown\", KeyMask: \"ark-u***003\",\n\t\t\tSource: \"l1_passive\", UpdatedAt: updatedAt.Add(2 * time.Minute),\n\t\t},\n\t})\n\n\tif !known {\n\t\tt.Fatal(\"自动发现的空模型清单仍应标记为已知\")\n\t}\n\tif got, want := strings.Join(models, \",\"), \"model-a\"; got != want {\n\t\tt.Fatalf(\"models=%q, want %q\", got, want)\n\t}\n\tif len(bindings) != 2 {\n\t\tt.Fatalf(\"bindings=%+v, want only two known inventories\", bindings)\n\t}\n\tif got := bindings[0]; got.KeyMask != \"ark-e***001\" || len(got.Models) != 0 {\n\t\tt.Fatalf(\"empty discovery binding=%+v, want empty ark-e binding\", got)\n\t}\n\tif latest == nil || !latest.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"latest=%v, want discovered inventory update time\", latest)\n\t}\n}\n\nfunc TestManagedChannelModelAvailabilityIncludesDiscoveryMetadata(t *testing.T) {\n\tfirst := time.Date(2026, 7, 22, 0, 42, 12, 0, time.UTC)\n\tsecond := first.Add(time.Minute)\n\tinventory := managedChannelModelAvailabilityDetails([]*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep-a\",\n\t\t\tCredentialUID: \"cred-a\",\n\t\t\tKeyMask: \"ark-a***001\",\n\t\t\tAvailableModels: []string{\"glm-5.2\"},\n\t\t\tUpdatedAt: first,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceControlPlane,\n\t\t\tModelDiscoveryMessage: \"火山管控面 Coding Plan 模型清单\",\n\t\t\tModelsDiscoveredAt: &first,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep-b\",\n\t\t\tCredentialUID: \"cred-b\",\n\t\t\tKeyMask: \"ark-b***002\",\n\t\t\tAvailableModels: []string{\"deepseek-v4-pro\"},\n\t\t\tUpdatedAt: second,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceBuiltinFallback,\n\t\t\tModelDiscoveryMessage: \"管控面失败,已回退内置模型清单\",\n\t\t\tModelsDiscoveredAt: &second,\n\t\t},\n\t})\n\n\tif !inventory.known || inventory.source != \"mixed\" {\n\t\tt.Fatalf(\"inventory source=%q known=%v\", inventory.source, inventory.known)\n\t}\n\tif !inventory.latestDiscoveredAt.Equal(second) || inventory.message != \"管控面失败,已回退内置模型清单\" {\n\t\tt.Fatalf(\"inventory discovery metadata=%+v\", inventory)\n\t}\n\tif len(inventory.bindings) != 2 || inventory.bindings[1].ModelsDiscoveredAt == nil {\n\t\tt.Fatalf(\"binding discovery metadata=%+v\", inventory.bindings)\n\t}\n}\n\nfunc TestManagedProtocolAvailabilityViewsAggregatesByProtocolAndCredential(t *testing.T) {\n\tdiscoveredAt := time.Date(2026, 7, 25, 10, 0, 0, 0, time.UTC)\n\tprofiles := []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep-a\", CredentialUID: \"cred-a\", KeyMask: \"sk-a***001\", ServiceType: \"responses\",\n\t\t\tUpdatedAt: discoveredAt, ProtocolModels: map[string][]string{\n\t\t\t\t\"responses\": {\"shared\", \"responses-only\"},\n\t\t\t\t\"chat\": {\"shared\"},\n\t\t\t},\n\t\t\tProtocolDiscoveredAt: map[string]time.Time{\"responses\": discoveredAt, \"chat\": discoveredAt},\n\t\t\tProtocolDiscoverySource: map[string]string{\"responses\": \"models_api\", \"chat\": \"protocol_probe\"},\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep-b\", CredentialUID: \"cred-b\", KeyMask: \"sk-b***002\", ServiceType: \"responses\",\n\t\t\tUpdatedAt: discoveredAt, ProtocolModels: map[string][]string{\n\t\t\t\t\"responses\": {\"shared\"},\n\t\t\t\t\"messages\": {\"shared\", \"messages-only\"},\n\t\t\t},\n\t\t\tProtocolDiscoveredAt: map[string]time.Time{\"responses\": discoveredAt, \"messages\": discoveredAt},\n\t\t\tProtocolDiscoverySource: map[string]string{\"responses\": \"models_api\", \"messages\": \"protocol_probe\"},\n\t\t},\n\t}\n\n\tviews := managedProtocolAvailabilityViews(profiles)\n\tif len(views) != 3 {\n\t\tt.Fatalf(\"views=%+v\", views)\n\t}\n\tif views[0].Protocol != \"messages\" || strings.Join(views[0].DiscoveredModels, \",\") != \"messages-only,shared\" {\n\t\tt.Fatalf(\"messages view=%+v\", views[0])\n\t}\n\tif views[2].Protocol != \"responses\" || len(views[2].ModelBindings) != 2 {\n\t\tt.Fatalf(\"responses view=%+v\", views[2])\n\t}\n}\n\nfunc TestPatchAccountCredentialsRemovesByUID(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"sk-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"sk-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"baseUrls\":[\"https://api.xiaomimimo.com/anthropic\"],\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPatch, \"/api/accounts/acct_test/credentials\", bytes.NewBufferString(`{\"removeCredentialUids\":[\"cred_b\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PATCH credentials status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 1 || len(channels[0].Upstream.APIKeys) != 1 || channels[0].Upstream.APIKeys[0] != \"sk-a\" {\n\t\tt.Fatalf(\"按 credentialUid 删除失败: %+v\", channels)\n\t}\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestInferAutoAddProviderID(t *testing.T) {\n\tzhipuKey := \"0123456789abcdef0123456789abcdef.ABCDEFGHIJKLMNO1\"\n\ttests := []struct {\n\t\tname string\n\t\tbaseURLs []string\n\t\tapiKeys []string\n\t\twant string\n\t}{\n\t\t{name: \"官方 Claude URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"官方 OpenAI URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4/\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"两个官方协议 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\", \"https://open.bigmodel.cn/api/paas/v4\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"仅智谱 Key\", apiKeys: []string{zhipuKey}, want: \"glm\"},\n\t\t{name: \"第三方 URL 不按 Key 提升\", baseURLs: []string{\"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"混合官方与第三方 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4\", \"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"共享 sk Key 无法推断\", apiKeys: []string{\"sk-abcdefghijklmnopqrstuvwxyz123456\"}},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := inferAutoAddProviderID(tt.baseURLs, tt.apiKeys); got != tt.want {\n\t\t\t\tt.Fatalf(\"inferAutoAddProviderID() = %q, want %q\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestApplyProviderUpstreamDefaults(t *testing.T) {\n\t// shouldPassbackReasoningContentByDefault 按 ProviderID+ServiceType 静态推导(GLM 已知\n\t// 协议事实),ApplyProviderUpstreamDefaults 不再写字段,测试必须设置 ProviderID 才能命中。\n\tglmChat := config.UpstreamConfig{ProviderID: \"glm\", ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmChat)\n\tif glmChat.ReasoningParamStyle != \"reasoning_effort\" || !glmChat.IsPassbackReasoningContentEnabled() {\n\t\tt.Fatalf(\"GLM OpenAI 默认兼容参数未补齐: %+v\", glmChat)\n\t}\n\n\tglmClaude := config.UpstreamConfig{ProviderID: \"glm\", ServiceType: \"claude\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmClaude)\n\tif glmClaude.ReasoningParamStyle != \"\" || glmClaude.IsPassbackReasoningContentEnabled() {\n\t\tt.Fatalf(\"GLM Claude 原生 route 不应注入 OpenAI 参数: %+v\", glmClaude)\n\t}\n\n\tcustom := config.UpstreamConfig{ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"\", &custom)\n\tif custom.ReasoningParamStyle != \"\" || custom.IsPassbackReasoningContentEnabled() {\n\t\tt.Fatalf(\"自定义渠道不应应用 GLM 默认值: %+v\", custom)\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCustomAutoAddResponseIncludesActualRoute(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\tdiscoverer := NewRateLimitDiscoverer(RateLimitDiscovererConfig{PassiveAimdEnabled: true, QuietLogs: true})\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, RateLimitDiscoverer: discoverer})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"fastaitoken-com-test\",\"baseUrls\":[\"https://example.com\"],\"apiKeys\":[\"sk-test\"],\"rateLimitHint\":{\"initialRpm\":30,\"effectiveRpm\":15,\"rateLimited\":true,\"rateLimitedCount\":1}}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 {\n\t\tt.Fatalf(\"channels=%+v, want one actual route\", response.Channels)\n\t}\n\troute := response.Channels[0]\n\tif route.ChannelKind != \"responses\" || route.ServiceType != \"responses\" || route.ChannelUID != response.ChannelUID {\n\t\tt.Fatalf(\"route=%+v response=%+v\", route, response)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.ResponsesUpstream) != 1 || len(cfg.Upstream) != 0 {\n\t\tt.Fatalf(\"custom route persisted in wrong channel: responses=%d messages=%d\", len(cfg.ResponsesUpstream), len(cfg.Upstream))\n\t}\n\tcreated := cfg.ResponsesUpstream[0]\n\tbaseURL := created.GetEffectiveBaseURL()\n\tendpointUID := GenerateEndpointUID(created.ChannelUID, baseURL, KeyHashFromAPIKey(\"sk-test\"))\n\tif suggestion := discoverer.SuggestedLimit(endpointUID); suggestion.RPM != 15 || suggestion.Confidence != 0.7 {\n\t\tt.Fatalf(\"rate limit suggestion=%+v, want rpm=15 confidence=0.7\", suggestion)\n\t}\n\n\tstore := newTestProfileStore(t)\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\tseedAutoAddRateLimitHint(\n\t\t&AutoManagedDeps{CfgManager: manager, Runner: runner, RateLimitDiscoverer: discoverer},\n\t\tmanager.GetAccountChannels(response.AccountUID),\n\t\t&AutoAddRateLimitHint{InitialRPM: 30, EffectiveRPM: 15, RateLimited: true, RateLimitedCount: 1},\n\t\t\"sk-test\",\n\t\t\"https://example.com\",\n\t)\n\tprofile := store.Get(endpointUID)\n\tif profile == nil || profile.DiscoveredRPM != 15 || profile.RateLimitConfidence != 0.7 || profile.RateLimitSource != string(RateLimitSourcePassiveAIMD) {\n\t\tt.Fatalf(\"rate limit profile=%+v\", profile)\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyToEquivalentExistingBaseURL(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [],\n \"chatUpstream\": [{\"channelUid\":\"ch_existing\",\"name\":\"localhost-old-name\",\"serviceType\":\"openai\",\"baseUrl\":\"http://localhost:8990/v1\",\"apiKeys\":[\"sk-old\"],\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"localhost-8990\",\"baseUrls\":[\"http://localhost:8990/\"],\"apiKeys\":[\"sk-new\"]}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 || response.Channels[0].Name != \"localhost-old-name\" {\n\t\tt.Fatalf(\"应返回已有渠道: %+v\", response.Channels)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 0 || len(cfg.ChatUpstream) != 1 {\n\t\tt.Fatalf(\"重复请求不应创建新渠道: messages=%d chat=%d\", len(cfg.Upstream), len(cfg.ChatUpstream))\n\t}\n\tif got := strings.Join(cfg.ChatUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"已有渠道密钥=%q, want sk-old,sk-new\", got)\n\t}\n}\n\nfunc TestFindExistingAutoAddChannelsPreservesHashSemantics(t *testing.T) {\n\tcfg := config.Config{\n\t\tChatUpstream: []config.UpstreamConfig{{\n\t\t\tChannelUID: \"ch-hash\", ServiceType: \"openai\", BaseURL: \"https://example.com#\",\n\t\t}},\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com\"}); len(matches) != 0 {\n\t\tt.Fatalf(\"普通 Base URL 不应匹配禁止版本前缀的地址: %+v\", matches)\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com/#\"}); len(matches) != 1 {\n\t\tt.Fatalf(\"等效 # 地址匹配数量=%d, want 1\", len(matches))\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyAndMissingRouteToManagedAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [{\"accountUid\":\"acct_existing\",\"channelUid\":\"ch_existing\",\"name\":\"localhost-8990\",\"serviceType\":\"claude\",\"baseUrl\":\"http://localhost:8990\",\"apiKeys\":[\"sk-old\"],\"autoManaged\":true,\"status\":\"active\"}],\n \"chatUpstream\": [], \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"localhost-8990\",\n \"baseUrls\":[\"http://localhost:8990/v1\"],\n \"apiKeys\":[\"sk-new\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"claude-test\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"gpt-test\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d\", len(cfg.Upstream), len(cfg.ResponsesUpstream))\n\t}\n\tif cfg.Upstream[0].AccountUID != \"acct_existing\" || cfg.ResponsesUpstream[0].AccountUID != \"acct_existing\" {\n\t\tt.Fatalf(\"应复用已有账号: messages=%q responses=%q\", cfg.Upstream[0].AccountUID, cfg.ResponsesUpstream[0].AccountUID)\n\t}\n\tif got := strings.Join(cfg.Upstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"messages 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif got := strings.Join(cfg.ResponsesUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"responses 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif cfg.Upstream[0].Name != \"localhost-8990-claude\" || cfg.ResponsesUpstream[0].Name != \"localhost-8990-codex\" {\n\t\tt.Fatalf(\"多协议自动命名错误: messages=%q responses=%q\", cfg.Upstream[0].Name, cfg.ResponsesUpstream[0].Name)\n\t}\n}\n\nfunc TestCustomAutoAddCreatesAllDetectedRoutesWithProtocolModels(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"fastaitoken-com-test\",\n \"baseUrls\":[\"https://example.com/keys\"],\n \"apiKeys\":[\"sk-test\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"shared\",\"messages-only\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"shared\",\"responses-only\"]},\n {\"channelKind\":\"chat\",\"supportedModels\":[\"shared\",\"chat-only\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 3 {\n\t\tt.Fatalf(\"channels=%+v, want three detected routes\", response.Channels)\n\t}\n\tfor _, route := range response.Channels {\n\t\tif route.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route account=%q, want %q\", route.AccountUID, response.AccountUID)\n\t\t}\n\t}\n\tif response.Channels[1].ChannelKind != \"responses\" || response.ChannelUID != response.Channels[1].ChannelUID {\n\t\tt.Fatalf(\"请求协议应作为主路由: response=%+v\", response)\n\t}\n\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 || len(cfg.ChatUpstream) != 1 || len(cfg.GeminiUpstream) != 0 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d chat=%d gemini=%d\",\n\t\t\tlen(cfg.Upstream), len(cfg.ResponsesUpstream), len(cfg.ChatUpstream), len(cfg.GeminiUpstream))\n\t}\n\tchecks := []struct {\n\t\tchannel config.UpstreamConfig\n\t\tname string\n\t\tmodels []string\n\t}{\n\t\t{cfg.Upstream[0], \"fastaitoken-com-test-claude\", []string{\"shared\", \"messages-only\"}},\n\t\t{cfg.ResponsesUpstream[0], \"fastaitoken-com-test-codex\", []string{\"shared\", \"responses-only\"}},\n\t\t{cfg.ChatUpstream[0], \"fastaitoken-com-test-chat\", []string{\"shared\", \"chat-only\"}},\n\t}\n\tfor _, check := range checks {\n\t\tif check.channel.Name != check.name || check.channel.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route identity=%+v, want name=%q account=%q\", check.channel, check.name, response.AccountUID)\n\t\t}\n\t\tif strings.Join(check.channel.SupportedModels, \",\") != strings.Join(check.models, \",\") {\n\t\t\tt.Fatalf(\"route %s models=%v, want %v\", check.name, check.channel.SupportedModels, check.models)\n\t\t}\n\t}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesRenamesAndSyncsCredentials(t *testing.T) {\n\tchannels := []config.AccountChannel{\n\t\t{Kind: \"messages\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-messages\", Name: \"old-claude\",\n\t\t\tServiceType: \"claude\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t\t{Kind: \"responses\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-responses\", Name: \"old-codex\",\n\t\t\tServiceType: \"responses\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t}\n\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\tName: \"renamed\", APIKeys: []string{\"sk-old\", \"sk-new\"},\n\t}, channels, len(channels))\n\tif err != nil || status != http.StatusOK {\n\t\tt.Fatalf(\"status=%d err=%v\", status, err)\n\t}\n\tif len(updates) != 2 || updates[0].Name != \"renamed-claude\" || updates[1].Name != \"renamed-codex\" {\n\t\tt.Fatalf(\"route names=%+v\", updates)\n\t}\n\tfor _, update := range updates {\n\t\tif len(update.APIKeyConfig) != 2 || update.APIKeyConfig[1].BaseURL != \"https://example.com\" {\n\t\t\tt.Fatalf(\"route credentials=%+v\", update.APIKeyConfig)\n\t\t}\n\t\tif update.APIKeyConfig[1].CredentialUID == \"\" {\n\t\t\tt.Fatalf(\"new credential missing stable uid: %+v\", update.APIKeyConfig[1])\n\t\t}\n\t}\n}\n\nfunc TestProviderRouteNameAndPrimaryResult(t *testing.T) {\n\tbase := \"mimo-test\"\n\ttests := []struct {\n\t\troute config.ProviderRoute\n\t\twant string\n\t}{\n\t\t{route: config.ProviderRoute{ChannelKind: \"messages\"}, want: \"mimo-test-claude\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"chat\"}, want: \"mimo-test-chat\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"responses\"}, want: \"mimo-test-codex\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"gemini\"}, want: \"mimo-test-gemini\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := providerRouteName(base, tt.route, true); got != tt.want {\n\t\t\tt.Fatalf(\"providerRouteName(%s)=%q, want %q\", tt.route.ChannelKind, got, tt.want)\n\t\t}\n\t}\n\tif got := providerRouteName(base, config.ProviderRoute{ChannelKind: \"messages\"}, false); got != base {\n\t\tt.Fatalf(\"single route name=%q, want %q\", got, base)\n\t}\n\n\tresults := []AutoAddChannelResult{\n\t\t{ChannelKind: \"messages\", ChannelUID: \"ch_messages\", Index: 1},\n\t\t{ChannelKind: \"chat\", ChannelUID: \"ch_chat\", Index: 2},\n\t\t{ChannelKind: \"responses\", ChannelUID: \"ch_responses\", Index: 3},\n\t}\n\tprimary := primaryAutoAddResult(results, \"chat\")\n\tif primary.ChannelUID != \"ch_chat\" || primary.Index != 2 {\n\t\tt.Fatalf(\"primary=%+v, want chat result\", primary)\n\t}\n}\n\nfunc TestManagedAccountUIDForProviderUsesExistingAccount(t *testing.T) {\n\tcfg := config.Config{ManagedAccounts: []config.ManagedAccountConfig{\n\t\t{AccountUID: \"acct-old\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-new\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-deepseek\", ProviderID: \"deepseek\"},\n\t}}\n\tif got := managedAccountUIDForProvider(cfg, \"mimo\"); got != \"acct-new\" {\n\t\tt.Fatalf(\"mimo existing account = %q, want acct-new\", got)\n\t}\n\tif got := managedAccountUIDForProvider(cfg, \"unknown\"); got != \"\" {\n\t\tt.Fatalf(\"unknown provider 不应命中账号: %q\", got)\n\t}\n}\n\nfunc TestListManagedAccountsDoesNotExposeVolcengineSecret(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-secret\",\"volcengineAccessKey\":{\"accessKeyId\":\"AKIDEXAMPLE\",\"secretAccessKey\":\"SECRET_MUST_NOT_LEAK\",\"plan\":\"agent_plan\",\"planTier\":\"Large\",\"planStatus\":\"Running\"}}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"SECRET_MUST_NOT_LEAK\") || strings.Contains(body, \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", body)\n\t}\n\tfor _, expected := range []string{`\"hasVolcengineAccessKey\":true`, `\"volcenginePlan\":\"agent_plan\"`, `\"volcenginePlanTier\":\"Large\"`} {\n\t\tif !strings.Contains(body, expected) {\n\t\t\tt.Fatalf(\"响应缺少 %s: %s\", expected, body)\n\t\t}\n\t}\n}\n\nfunc TestKimiConsoleTokenLifecycleDoesNotExposeToken(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_kimi\",\"providerId\":\"kimi\",\"name\":\"kimi\",\"credentials\":[{\"credentialUid\":\"cred_kimi\",\"apiKey\":\"sk-kimi-secret\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_kimi\",\"channelUid\":\"ch_kimi\",\"providerId\":\"kimi\",\"name\":\"kimi\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.kimi.com/coding\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_kimi\",\"baseUrl\":\"https://api.kimi.com/coding\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tkimiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Authorization\") != \"Bearer web-session-secret\" {\n\t\t\tt.Fatalf(\"Kimi 请求未使用保存的会话令牌\")\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase kimiUsagesPath:\n\t\t\t_, _ = w.Write([]byte(`{\"usages\":[{\"scope\":\"FEATURE_CODING\",\"detail\":{\"limit\":\"100\",\"remaining\":\"80\",\"resetTime\":\"2026-07-27T16:13:43Z\"},\"limits\":[{\"window\":{\"duration\":300,\"timeUnit\":\"TIME_UNIT_MINUTE\"},\"detail\":{\"limit\":\"100\",\"remaining\":\"90\",\"resetTime\":\"2026-07-21T02:13:43Z\"}}]}],\"totalQuota\":{\"limit\":\"100\",\"remaining\":\"80\"}}`))\n\t\tcase kimiSubscriptionStatsPath:\n\t\t\t_, _ = w.Write([]byte(`{\"ratelimitCode5h\":{\"ratio\":0.1,\"enabled\":true},\"ratelimitCode7d\":{\"ratio\":0.2,\"enabled\":true},\"subscriptionBalance\":{\"feature\":\"FEATURE_OMNI\",\"type\":\"SUBSCRIPTION\",\"unit\":\"UNIT_CREDIT\",\"amountUsedRatio\":0.2,\"kimiCodeUsedRatio\":0.2,\"expireTime\":\"2026-08-20T16:14:07Z\"}}`))\n\t\tdefault:\n\t\t\thttp.NotFound(w, r)\n\t\t}\n\t}))\n\tdefer kimiServer.Close()\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: cfgManager,\n\t\tKimiConsoleClient: &KimiConsoleClient{\n\t\t\tHTTPClient: kimiServer.Client(), BaseURL: kimiServer.URL,\n\t\t},\n\t})\n\tput := httptest.NewRequest(\n\t\thttp.MethodPut,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token\",\n\t\tbytes.NewBufferString(`{\"accessToken\":\"Bearer web-session-secret\"}`),\n\t)\n\tput.Header.Set(\"Content-Type\", \"application/json\")\n\tputRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(putRecorder, put)\n\tif putRecorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"绑定 Kimi 令牌失败: status=%d body=%s\", putRecorder.Code, putRecorder.Body.String())\n\t}\n\tif strings.Contains(putRecorder.Body.String(), \"web-session-secret\") || strings.Contains(putRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"绑定响应泄露 Kimi 令牌: %s\", putRecorder.Body.String())\n\t}\n\tcredential, ok := cfgManager.GetManagedAccountCredential(\"acct_kimi\", \"cred_kimi\")\n\tif !ok || credential.KimiConsole == nil || credential.KimiConsole.AccessToken != \"web-session-secret\" {\n\t\tt.Fatalf(\"Kimi 令牌未持久化: %+v\", credential)\n\t}\n\tpersisted, err := os.ReadFile(configPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !strings.Contains(string(persisted), `\"accessToken\": \"web-session-secret\"`) {\n\t\tt.Fatalf(\"配置文件未保存 Kimi 令牌\")\n\t}\n\n\tlistRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(listRecorder, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif listRecorder.Code != http.StatusOK || strings.Contains(listRecorder.Body.String(), \"web-session-secret\") || strings.Contains(listRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"账号列表响应异常或泄露令牌: status=%d body=%s\", listRecorder.Code, listRecorder.Body.String())\n\t}\n\tif !strings.Contains(listRecorder.Body.String(), `\"hasKimiConsoleToken\":true`) || !strings.Contains(listRecorder.Body.String(), `\"kimiCodeUsage\"`) {\n\t\tt.Fatalf(\"账号列表缺少 Kimi 用量快照: %s\", listRecorder.Body.String())\n\t}\n\n\trefreshRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(refreshRecorder, httptest.NewRequest(\n\t\thttp.MethodPost,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token/refresh\",\n\t\tnil,\n\t))\n\tif refreshRecorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"刷新 Kimi 余量失败: status=%d body=%s\", refreshRecorder.Code, refreshRecorder.Body.String())\n\t}\n\tif strings.Contains(refreshRecorder.Body.String(), \"web-session-secret\") || strings.Contains(refreshRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"刷新响应泄露 Kimi 令牌: %s\", refreshRecorder.Body.String())\n\t}\n\n\tdeleteRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(deleteRecorder, httptest.NewRequest(\n\t\thttp.MethodDelete,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token\",\n\t\tnil,\n\t))\n\tif deleteRecorder.Code != http.StatusNoContent {\n\t\tt.Fatalf(\"解绑 Kimi 令牌失败: status=%d body=%s\", deleteRecorder.Code, deleteRecorder.Body.String())\n\t}\n\tcredential, _ = cfgManager.GetManagedAccountCredential(\"acct_kimi\", \"cred_kimi\")\n\tif credential.KimiConsole != nil {\n\t\tt.Fatalf(\"Kimi 令牌解绑后仍存在: %+v\", credential.KimiConsole)\n\t}\n\tpersisted, err = os.ReadFile(configPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(persisted), \"web-session-secret\") {\n\t\tt.Fatalf(\"解绑后配置文件仍包含 Kimi 令牌\")\n\t}\n}\n\nfunc TestSetVolcengineAccessKeyDetectsPlanBeforePersisting(t *testing.T) {\n\tcontrolPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Query().Get(\"Action\") != \"GetPersonalPlan\" {\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tvar body struct{ Plan string }\n\t\t_ = json.NewDecoder(r.Body).Decode(&body)\n\t\tif body.Plan == \"CodingPlan\" {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t_, _ = w.Write([]byte(`{\"ResponseMetadata\":{\"Error\":{\"Code\":\"ResourceNotFound.Plan\",\"Message\":\"not found\"}}}`))\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\"Result\":{\"PlanType\":\"Large\",\"Status\":\"Running\"}}`))\n\t}))\n\tdefer controlPlane.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-inference\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trunner := NewAutoDiscoveryRunner(nil, nil)\n\trunner.client = controlPlane.Client()\n\trunner.volcengineControlPlaneEndpoint = controlPlane.URL\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, Runner: runner})\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_volc/credentials/cred_volc/volcengine-access-key\", bytes.NewBufferString(`{\"accessKeyId\":\"AKID\",\"secretAccessKey\":\"SECRET\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"plan\":\"agent_plan\"`) {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_volc\", \"cred_volc\")\n\tif !ok || credential.VolcengineAccessKey == nil || credential.VolcengineAccessKey.SecretAccessKey != \"SECRET\" || credential.VolcengineAccessKey.Plan != \"agent_plan\" {\n\t\tt.Fatalf(\"凭证未正确持久化: %+v\", credential)\n\t}\n}\n\nfunc TestSetMiMoConsoleCookieRequiresConfirmationBeforeAdoptingKey(t *testing.T) {\n\tconst oldKey = \"tp-old-1234567890123456789012345678901234567890\"\n\tconst cookieKey = \"tp-new-1234567890123456789012345678901234567890\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != \"api-platform_serviceToken=session; userId=42\" {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase \"/api/v1/userProfile\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"userId\":\"42\"}}`))\n\t\tcase \"/api/v1/tokenPlan/detail\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"planCode\":\"max\",\"planName\":\"Max\",\"currentPeriodEnd\":\"2026-07-29 23:59:59\"}}`))\n\t\tcase \"/api/v1/tokenPlan/usage\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"monthUsage\":{\"percent\":0.25,\"items\":[{\"used\":25,\"limit\":100}]},\"usage\":{\"percent\":0.5,\"items\":[{\"used\":50,\"limit\":100}]}}}`))\n\t\tcase \"/api/v1/tokenPlan/apiKey/raw\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":\"` + cookieKey + `\"}`))\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}\n\t}))\n\tdefer console.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"credentials\":[{\"credentialUid\":\"cred_mimo\",\"apiKey\":\"` + oldKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_mimo\",\"channelUid\":\"ch_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_mimo\",\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\tdeps := &AutoManagedDeps{CfgManager: manager, MiMoConsoleClient: &MiMoConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL}}\n\trouter := setupAutoManagedRouter(deps)\n\tbody := `{\"cookie\":\"api-platform_serviceToken=session; userId=42\"}`\n\trequest := func(payload string) *httptest.ResponseRecorder {\n\t\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_mimo/credentials/cred_mimo/mimo-console-cookie\", bytes.NewBufferString(payload))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tw := httptest.NewRecorder()\n\t\trouter.ServeHTTP(w, req)\n\t\treturn w\n\t}\n\tw := request(body)\n\tif w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), `\"code\":\"mimo_cookie_key_mismatch\"`) {\n\t\tt.Fatalf(\"未要求确认: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ := manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != oldKey || credential.MiMoConsole != nil {\n\t\tt.Fatalf(\"确认前不应修改配置: %+v\", credential)\n\t}\n\tw = request(`{\"cookie\":\"api-platform_serviceToken=session; userId=42\",\"adoptCookieKey\":true}`)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"keyAdopted\":true`) {\n\t\tt.Fatalf(\"确认采用失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ = manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != cookieKey || credential.MiMoConsole == nil {\n\t\tt.Fatalf(\"确认后未采用 Cookie Key: %+v\", credential)\n\t}\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif strings.Contains(list.Body.String(), \"api-platform_serviceToken\") || strings.Contains(list.Body.String(), cookieKey) {\n\t\tt.Fatalf(\"账号列表泄漏 Cookie 或原始 Key: %s\", list.Body.String())\n\t}\n}\n\nfunc TestCompshareConsoleCookieBindingDoesNotExposeSecrets(t *testing.T) {\n\tconst currentKey = \"sk-cp-test-current\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != testCompshareCookie {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\n \"RetCode\": 0,\n \"UserPlans\": [{\n \"Code\": \"cppkg-test\", \"PlanCode\": \"cp-plan-test\", \"PlanName\": \"Pro\", \"DisplayName\": \"Pro 增强版\",\n \"LimitPer5h\": 3000, \"LimitPerWeek\": 7500, \"LimitPerMonth\": 19000,\n \"UsagePer5h\": 0, \"UsagePerWeek\": 100, \"UsagePerMonth\": 6496,\n \"ConcurrencyLimit\": 10, \"Status\": 1, \"ExpireAt\": 1785037981,\n \"Keys\": [{\"APIKey\": \"` + currentKey + `\"}]\n }]\n}`))\n\t}))\n\tdefer console.Close()\n\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_compshare\",\"providerId\":\"compshare\",\"name\":\"compshare\",\"credentials\":[{\"credentialUid\":\"cred_compshare\",\"apiKey\":\"` + currentKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_compshare\",\"channelUid\":\"ch_compshare\",\"providerId\":\"compshare\",\"name\":\"compshare\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://api.compshare.cn/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_compshare\",\"baseUrl\":\"https://api.compshare.cn/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: manager,\n\t\tCompshareConsoleClient: &CompshareConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL},\n\t})\n\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_compshare/credentials/cred_compshare/compshare-console-cookie\", bytes.NewBufferString(`{\"cookie\":\"`+testCompshareCookie+`\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"displayName\":\"Pro 增强版\"`) {\n\t\tt.Fatalf(\"绑定失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_compshare\", \"cred_compshare\")\n\tif !ok || credential.CompshareConsole == nil || credential.CompshareConsole.Cookie != testCompshareCookie {\n\t\tt.Fatalf(\"控制台套餐未持久化: %+v\", credential)\n\t}\n\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tresponse := list.Body.String()\n\tfor _, secret := range []string{\"U_JWT_TOKEN\", \"test-session\", \"test_user@console.compshare.cn\", \"org-test\", currentKey, \"cppkg-test\"} {\n\t\tif strings.Contains(response, secret) {\n\t\t\tt.Fatalf(\"账号列表泄漏敏感字段 %q: %s\", secret, response)\n\t\t}\n\t}\n\tfor _, expected := range []string{`\"hasCompshareConsoleCookie\":true`, `\"planCode\":\"cp-plan-test\"`, `\"monthlyUsage\":{\"used\":6496,\"limit\":19000`} {\n\t\tif !strings.Contains(response, expected) {\n\t\t\tt.Fatalf(\"账号列表缺少套餐字段 %s: %s\", expected, response)\n\t\t}\n\t}\n}\n\nfunc TestProviderAutoAddReusesExistingAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(`{\"providerId\":\"mimo\",\"apiKeys\":[\"sk-existing\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"追加已有 provider key status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 4 {\n\t\tt.Fatalf(\"旧账号应自动补齐模板中的协议渠道: %+v\", channels)\n\t}\n\tkinds := make(map[string]bool, len(channels))\n\tfor _, channel := range channels {\n\t\tkinds[channel.Kind] = true\n\t}\n\tfor _, kind := range []string{\"messages\", \"chat\", \"responses\", \"gemini\"} {\n\t\tif !kinds[kind] {\n\t\t\tt.Fatalf(\"旧账号缺少 %s route: %+v\", kind, channels)\n\t\t}\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.AccountUID != \"acct_test\" {\n\t\tt.Fatalf(\"响应未返回已有账号: body=%s err=%v\", w.Body.String(), err)\n\t}\n\tif len(response.Channels) != 4 {\n\t\tt.Fatalf(\"响应未返回补齐后的全部渠道: %+v\", response.Channels)\n\t}\n}\n\nfunc TestMissingProviderAccountRoutes(t *testing.T) {\n\ttmpl, ok := config.GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"缺少 deepseek provider 模板\")\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{ServiceType: \"claude\"},\n\t}}\n\tmissing := missingProviderAccountRoutes(tmpl, existing)\n\tif len(missing) != 2 || missing[0].ChannelKind != \"chat\" || missing[1].ChannelKind != \"responses\" {\n\t\tt.Fatalf(\"missing routes = %+v\", missing)\n\t}\n\tconfigs, baseURLs, err := bindProviderRouteKeys(tmpl, missing[0], []string{\"sk-existing\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(configs) != 1 || configs[0].BaseURL != \"https://api.deepseek.com\" || len(baseURLs) != 1 {\n\t\tt.Fatalf(\"route 绑定结果不正确: configs=%+v baseURLs=%v\", configs, baseURLs)\n\t}\n}\n\nfunc TestBindProviderRouteKeysPreservesExistingEndpointAffinity(t *testing.T) {\n\ttests := []struct {\n\t\tproviderID string\n\t\tapiKey string\n\t\texistingBaseURL string\n\t\ttargetKind string\n\t\twantBaseURL string\n\t}{\n\t\t{\n\t\t\tproviderID: \"mimo\", apiKey: \"tp-existing\",\n\t\t\texistingBaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\t\ttargetKind: \"chat\", wantBaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\",\n\t\t},\n\t\t{\n\t\t\tproviderID: \"volcengine\", apiKey: \"ark-existing\",\n\t\t\texistingBaseURL: \"https://ark.cn-beijing.volces.com/api/coding\",\n\t\t\ttargetKind: \"responses\", wantBaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.providerID, func(t *testing.T) {\n\t\t\ttmpl, ok := config.GetProviderTemplate(tt.providerID)\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"缺少 provider 模板: %s\", tt.providerID)\n\t\t\t}\n\t\t\texisting := []config.AccountChannel{{\n\t\t\t\tKind: \"messages\",\n\t\t\t\tUpstream: config.UpstreamConfig{\n\t\t\t\t\tProviderID: tt.providerID, ServiceType: \"claude\", APIKeys: []string{tt.apiKey},\n\t\t\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: tt.apiKey, BaseURL: tt.existingBaseURL}},\n\t\t\t\t},\n\t\t\t}}\n\t\t\tvar target config.ProviderRoute\n\t\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\t\tif route.ChannelKind == tt.targetKind {\n\t\t\t\t\ttarget = route\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tconfigs, _, err := bindProviderRouteKeysWithAffinities(\n\t\t\t\ttmpl, target, []string{tt.apiKey}, providerKeyCandidateAffinities(tmpl, existing),\n\t\t\t)\n\t\t\tif err != nil || len(configs) != 1 || configs[0].BaseURL != tt.wantBaseURL {\n\t\t\t\tt.Fatalf(\"亲和绑定错误: configs=%+v err=%v want=%s\", configs, err, tt.wantBaseURL)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPlanProviderAccountRouteAdditionsRejectsNewKeyBeforeMutation(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t}))\n\tdefer server.Close()\n\n\ttmpl := &config.ProviderTemplate{\n\t\tProviderID: \"test-provider\",\n\t\tRoutes: []config.ProviderRoute{\n\t\t\t{ChannelKind: \"messages\", ServiceType: \"claude\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/anthropic\"}}},\n\t\t\t{ChannelKind: \"chat\", ServiceType: \"openai\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/v1\"}}},\n\t\t},\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct_test\", ChannelUID: \"ch_messages\", Name: \"test-provider\",\n\t\t\tProviderID: \"test-provider\", ServiceType: \"claude\", AutoManaged: true, APIKeys: []string{\"sk-existing\"},\n\t\t},\n\t}}\n\tadditions, status, err := planProviderAccountRouteAdditions(\n\t\tt.Context(), config.Config{}, tmpl, \"acct_test\", []string{\"sk-existing\", \"sk-invalid\"}, existing,\n\t)\n\tif err == nil || status != http.StatusBadRequest || len(additions) != 0 {\n\t\tt.Fatalf(\"无效新 Key 应在生成新增 route 前失败: status=%d additions=%+v err=%v\", status, additions, err)\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}, "01e8421801c2c699d13f4ef638cda19f9af22414": {"t": 1785759334.9847472, "n": 58996, "h": "11840354cb666dec", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/BenedictKing/ccx/internal/errutil\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestListAccountsMasksCredentials(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-secret-value\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil)\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"sk-secret-value\") {\n\t\tt.Fatalf(\"账号列表泄露明文 Key: %s\", body)\n\t}\n\tif !strings.Contains(body, \"cred_test\") || !strings.Contains(body, \"keyMask\") {\n\t\tt.Fatalf(\"账号列表缺少凭证掩码信息: %s\", body)\n\t}\n}\n\nfunc TestListAccountsIncludesActiveEndpointModelAvailability(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"volcengine\",\"name\":\"volcengine\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"ark-secret-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"ark-secret-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"volcengine\",\"name\":\"volcengine-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://example.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tstore, err := NewProfileStoreWithDB(newTestDB(t))\n\tif err != nil {\n\t\tt.Fatalf(\"NewProfileStoreWithDB 失败: %v\", err)\n\t}\n\tupdatedAt := time.Date(2026, 7, 17, 10, 54, 33, 0, time.UTC)\n\tdiscoveredAt := updatedAt\n\tdiscoveredAtLater := updatedAt.Add(time.Minute)\n\tfor _, profile := range []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_a\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_a\", KeyMask: \"ark-a***001\", AvailableModels: []string{\"model-b\", \"model-a\"}, UpdatedAt: updatedAt,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI, ModelsDiscoveredAt: &discoveredAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_b\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_b\", KeyMask: \"ark-b***002\", AvailableModels: []string{\"model-c\", \"model-b\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI, ModelsDiscoveredAt: &discoveredAtLater,\n\t\t},\n\t} {\n\t\tif err := store.Upsert(profile); err != nil {\n\t\t\tt.Fatalf(\"ProfileStore.Upsert 失败: %v\", err)\n\t\t}\n\t}\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager, Runner: runner})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response struct {\n\t\tAccounts []managedAccountView `json:\"accounts\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatalf(\"解析响应失败: %v\", err)\n\t}\n\tif len(response.Accounts) != 1 || len(response.Accounts[0].Channels) != 1 {\n\t\tt.Fatalf(\"账号渠道数量错误: %+v\", response.Accounts)\n\t}\n\tchannel := response.Accounts[0].Channels[0]\n\tif got, want := strings.Join(channel.DiscoveredModels, \",\"), \"model-a,model-b,model-c\"; got != want {\n\t\tt.Fatalf(\"DiscoveredModels=%q, want %q\", got, want)\n\t}\n\tif !channel.ModelInventoryKnown {\n\t\tt.Fatal(\"已发现模型时 ModelInventoryKnown 应为 true\")\n\t}\n\tif len(channel.ModelBindings) != 2 {\n\t\tt.Fatalf(\"ModelBindings=%+v, want 2\", channel.ModelBindings)\n\t}\n\tif got := strings.Join(channel.ModelBindings[0].Models, \",\"); got != \"model-a,model-b\" {\n\t\tt.Fatalf(\"第一个 Key 模型=%q\", got)\n\t}\n\tif channel.ModelsUpdatedAt == nil || !channel.ModelsUpdatedAt.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"ModelsUpdatedAt=%v\", channel.ModelsUpdatedAt)\n\t}\n\tif strings.Contains(w.Body.String(), \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", w.Body.String())\n\t}\n}\n\nfunc TestListAccountsIncludesCustomAutoManagedProtocolAvailability(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [],\n \"upstream\": [], \"chatUpstream\": [],\n \"responsesUpstream\": [{\"accountUid\":\"acct_custom\",\"channelUid\":\"ch_custom\",\"name\":\"custom-endpoint\",\"serviceType\":\"responses\",\"baseUrl\":\"https://example.com\",\"apiKeys\":[\"sk-custom-secret\"],\"apiKeyConfigs\":[{\"key\":\"sk-custom-secret\",\"credentialUid\":\"cred_custom\",\"baseUrl\":\"https://example.com\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tstore, err := NewProfileStoreWithDB(newTestDB(t))\n\tif err != nil {\n\t\tt.Fatalf(\"NewProfileStoreWithDB 失败: %v\", err)\n\t}\n\tdiscoveredAt := time.Date(2026, 7, 25, 5, 11, 29, 0, time.UTC)\n\tprofile := &KeyEndpointProfile{\n\t\tEndpointUID: \"ep_custom\", AccountUID: \"acct_custom\", ChannelUID: \"ch_custom\",\n\t\tCredentialUID: \"cred_custom\", KeyMask: \"sk-c***ret\", ServiceType: \"responses\",\n\t\tAvailableModels: []string{\"gpt-5.6-sol\"}, ModelsDiscoveredAt: &discoveredAt,\n\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI,\n\t\tProtocolModels: map[string][]string{\n\t\t\t\"messages\": {\"gpt-5.6-sol\"},\n\t\t\t\"chat\": {\"gpt-5.6-sol\"},\n\t\t\t\"responses\": {\"gpt-5.6-sol\"},\n\t\t},\n\t\tProtocolDiscoveredAt: map[string]time.Time{\n\t\t\t\"messages\": discoveredAt, \"chat\": discoveredAt, \"responses\": discoveredAt,\n\t\t},\n\t\tProtocolDiscoverySource: map[string]string{\n\t\t\t\"messages\": \"protocol_probe\", \"chat\": \"protocol_probe\", \"responses\": \"models_api\",\n\t\t},\n\t\tUpdatedAt: discoveredAt,\n\t}\n\tif err := store.Upsert(profile); err != nil {\n\t\tt.Fatalf(\"ProfileStore.Upsert 失败: %v\", err)\n\t}\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: cfgManager,\n\t\tRunner: NewAutoDiscoveryRunner(store, nil),\n\t})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tif strings.Contains(w.Body.String(), \"sk-custom-secret\") {\n\t\tt.Fatalf(\"账号列表泄露自定义渠道明文 Key: %s\", w.Body.String())\n\t}\n\n\tvar response struct {\n\t\tAccounts []managedAccountView `json:\"accounts\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatalf(\"解析响应失败: %v\", err)\n\t}\n\tif len(response.Accounts) != 1 {\n\t\tt.Fatalf(\"Accounts=%+v, want 1\", response.Accounts)\n\t}\n\taccount := response.Accounts[0]\n\tif account.AccountUID != \"acct_custom\" || len(account.Credentials) != 1 || len(account.Channels) != 1 {\n\t\tt.Fatalf(\"自定义自动托管账号聚合错误: %+v\", account)\n\t}\n\tprotocols := make([]string, 0, len(account.Channels[0].ProtocolAvailability))\n\tfor _, availability := range account.Channels[0].ProtocolAvailability {\n\t\tprotocols = append(protocols, availability.Protocol)\n\t}\n\tif got, want := strings.Join(protocols, \",\"), \"messages,chat,responses\"; got != want {\n\t\tt.Fatalf(\"ProtocolAvailability=%q, want %q\", got, want)\n\t}\n}\n\nfunc TestManagedChannelModelAvailabilityIncludesEmptyAutoDiscoveryResult(t *testing.T) {\n\tupdatedAt := time.Date(2026, 7, 17, 11, 2, 0, 0, time.UTC)\n\tmodels, bindings, latest, known := managedChannelModelAvailability([]*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_empty\", CredentialUID: \"cred_empty\", KeyMask: \"ark-e***001\",\n\t\t\tSource: \"auto_discovery\", AvailableModels: []string{}, UpdatedAt: updatedAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_models\", CredentialUID: \"cred_models\", KeyMask: \"ark-m***002\",\n\t\t\tAvailableModels: []string{\"model-a\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_unknown\", CredentialUID: \"cred_unknown\", KeyMask: \"ark-u***003\",\n\t\t\tSource: \"l1_passive\", UpdatedAt: updatedAt.Add(2 * time.Minute),\n\t\t},\n\t})\n\n\tif !known {\n\t\tt.Fatal(\"自动发现的空模型清单仍应标记为已知\")\n\t}\n\tif got, want := strings.Join(models, \",\"), \"model-a\"; got != want {\n\t\tt.Fatalf(\"models=%q, want %q\", got, want)\n\t}\n\tif len(bindings) != 2 {\n\t\tt.Fatalf(\"bindings=%+v, want only two known inventories\", bindings)\n\t}\n\tif got := bindings[0]; got.KeyMask != \"ark-e***001\" || len(got.Models) != 0 {\n\t\tt.Fatalf(\"empty discovery binding=%+v, want empty ark-e binding\", got)\n\t}\n\tif latest == nil || !latest.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"latest=%v, want discovered inventory update time\", latest)\n\t}\n}\n\nfunc TestManagedChannelModelAvailabilityIncludesDiscoveryMetadata(t *testing.T) {\n\tfirst := time.Date(2026, 7, 22, 0, 42, 12, 0, time.UTC)\n\tsecond := first.Add(time.Minute)\n\tinventory := managedChannelModelAvailabilityDetails([]*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep-a\",\n\t\t\tCredentialUID: \"cred-a\",\n\t\t\tKeyMask: \"ark-a***001\",\n\t\t\tAvailableModels: []string{\"glm-5.2\"},\n\t\t\tUpdatedAt: first,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceControlPlane,\n\t\t\tModelDiscoveryMessage: \"火山管控面 Coding Plan 模型清单\",\n\t\t\tModelsDiscoveredAt: &first,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep-b\",\n\t\t\tCredentialUID: \"cred-b\",\n\t\t\tKeyMask: \"ark-b***002\",\n\t\t\tAvailableModels: []string{\"deepseek-v4-pro\"},\n\t\t\tUpdatedAt: second,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceBuiltinFallback,\n\t\t\tModelDiscoveryMessage: \"管控面失败,已回退内置模型清单\",\n\t\t\tModelsDiscoveredAt: &second,\n\t\t},\n\t})\n\n\tif !inventory.known || inventory.source != \"mixed\" {\n\t\tt.Fatalf(\"inventory source=%q known=%v\", inventory.source, inventory.known)\n\t}\n\tif !inventory.latestDiscoveredAt.Equal(second) || inventory.message != \"管控面失败,已回退内置模型清单\" {\n\t\tt.Fatalf(\"inventory discovery metadata=%+v\", inventory)\n\t}\n\tif len(inventory.bindings) != 2 || inventory.bindings[1].ModelsDiscoveredAt == nil {\n\t\tt.Fatalf(\"binding discovery metadata=%+v\", inventory.bindings)\n\t}\n}\n\nfunc TestManagedProtocolAvailabilityViewsAggregatesByProtocolAndCredential(t *testing.T) {\n\tdiscoveredAt := time.Date(2026, 7, 25, 10, 0, 0, 0, time.UTC)\n\tprofiles := []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep-a\", CredentialUID: \"cred-a\", KeyMask: \"sk-a***001\", ServiceType: \"responses\",\n\t\t\tUpdatedAt: discoveredAt, ProtocolModels: map[string][]string{\n\t\t\t\t\"responses\": {\"shared\", \"responses-only\"},\n\t\t\t\t\"chat\": {\"shared\"},\n\t\t\t},\n\t\t\tProtocolDiscoveredAt: map[string]time.Time{\"responses\": discoveredAt, \"chat\": discoveredAt},\n\t\t\tProtocolDiscoverySource: map[string]string{\"responses\": \"models_api\", \"chat\": \"protocol_probe\"},\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep-b\", CredentialUID: \"cred-b\", KeyMask: \"sk-b***002\", ServiceType: \"responses\",\n\t\t\tUpdatedAt: discoveredAt, ProtocolModels: map[string][]string{\n\t\t\t\t\"responses\": {\"shared\"},\n\t\t\t\t\"messages\": {\"shared\", \"messages-only\"},\n\t\t\t},\n\t\t\tProtocolDiscoveredAt: map[string]time.Time{\"responses\": discoveredAt, \"messages\": discoveredAt},\n\t\t\tProtocolDiscoverySource: map[string]string{\"responses\": \"models_api\", \"messages\": \"protocol_probe\"},\n\t\t},\n\t}\n\n\tviews := managedProtocolAvailabilityViews(profiles)\n\tif len(views) != 3 {\n\t\tt.Fatalf(\"views=%+v\", views)\n\t}\n\tif views[0].Protocol != \"messages\" || strings.Join(views[0].DiscoveredModels, \",\") != \"messages-only,shared\" {\n\t\tt.Fatalf(\"messages view=%+v\", views[0])\n\t}\n\tif views[2].Protocol != \"responses\" || len(views[2].ModelBindings) != 2 {\n\t\tt.Fatalf(\"responses view=%+v\", views[2])\n\t}\n}\n\nfunc TestPatchAccountCredentialsRemovesByUID(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"sk-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"sk-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"baseUrls\":[\"https://api.xiaomimimo.com/anthropic\"],\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPatch, \"/api/accounts/acct_test/credentials\", bytes.NewBufferString(`{\"removeCredentialUids\":[\"cred_b\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PATCH credentials status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 1 || len(channels[0].Upstream.APIKeys) != 1 || channels[0].Upstream.APIKeys[0] != \"sk-a\" {\n\t\tt.Fatalf(\"按 credentialUid 删除失败: %+v\", channels)\n\t}\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestInferAutoAddProviderID(t *testing.T) {\n\tzhipuKey := \"0123456789abcdef0123456789abcdef.ABCDEFGHIJKLMNO1\"\n\ttests := []struct {\n\t\tname string\n\t\tbaseURLs []string\n\t\tapiKeys []string\n\t\twant string\n\t}{\n\t\t{name: \"官方 Claude URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"官方 OpenAI URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4/\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"两个官方协议 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\", \"https://open.bigmodel.cn/api/paas/v4\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"仅智谱 Key\", apiKeys: []string{zhipuKey}, want: \"glm\"},\n\t\t{name: \"第三方 URL 不按 Key 提升\", baseURLs: []string{\"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"混合官方与第三方 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4\", \"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"共享 sk Key 无法推断\", apiKeys: []string{\"sk-abcdefghijklmnopqrstuvwxyz123456\"}},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := inferAutoAddProviderID(tt.baseURLs, tt.apiKeys); got != tt.want {\n\t\t\t\tt.Fatalf(\"inferAutoAddProviderID() = %q, want %q\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestApplyProviderUpstreamDefaults(t *testing.T) {\n\tglmChat := config.UpstreamConfig{ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmChat)\n\tif glmChat.ReasoningParamStyle != \"reasoning_effort\" || !glmChat.IsPassbackReasoningContentEnabled() {\n\t\tt.Fatalf(\"GLM OpenAI 默认兼容参数未补齐: %+v\", glmChat)\n\t}\n\n\tglmClaude := config.UpstreamConfig{ServiceType: \"claude\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmClaude)\n\tif glmClaude.ReasoningParamStyle != \"\" || glmClaude.IsPassbackReasoningContentEnabled() {\n\t\tt.Fatalf(\"GLM Claude 原生 route 不应注入 OpenAI 参数: %+v\", glmClaude)\n\t}\n\n\tcustom := config.UpstreamConfig{ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"\", &custom)\n\tif custom.ReasoningParamStyle != \"\" || custom.IsPassbackReasoningContentEnabled() {\n\t\tt.Fatalf(\"自定义渠道不应应用 GLM 默认值: %+v\", custom)\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCustomAutoAddResponseIncludesActualRoute(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\tdiscoverer := NewRateLimitDiscoverer(RateLimitDiscovererConfig{PassiveAimdEnabled: true, QuietLogs: true})\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, RateLimitDiscoverer: discoverer})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"fastaitoken-com-test\",\"baseUrls\":[\"https://example.com\"],\"apiKeys\":[\"sk-test\"],\"rateLimitHint\":{\"initialRpm\":30,\"effectiveRpm\":15,\"rateLimited\":true,\"rateLimitedCount\":1}}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 {\n\t\tt.Fatalf(\"channels=%+v, want one actual route\", response.Channels)\n\t}\n\troute := response.Channels[0]\n\tif route.ChannelKind != \"responses\" || route.ServiceType != \"responses\" || route.ChannelUID != response.ChannelUID {\n\t\tt.Fatalf(\"route=%+v response=%+v\", route, response)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.ResponsesUpstream) != 1 || len(cfg.Upstream) != 0 {\n\t\tt.Fatalf(\"custom route persisted in wrong channel: responses=%d messages=%d\", len(cfg.ResponsesUpstream), len(cfg.Upstream))\n\t}\n\tcreated := cfg.ResponsesUpstream[0]\n\tbaseURL := created.GetEffectiveBaseURL()\n\tendpointUID := GenerateEndpointUID(created.ChannelUID, baseURL, KeyHashFromAPIKey(\"sk-test\"))\n\tif suggestion := discoverer.SuggestedLimit(endpointUID); suggestion.RPM != 15 || suggestion.Confidence != 0.7 {\n\t\tt.Fatalf(\"rate limit suggestion=%+v, want rpm=15 confidence=0.7\", suggestion)\n\t}\n\n\tstore := newTestProfileStore(t)\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\tseedAutoAddRateLimitHint(\n\t\t&AutoManagedDeps{CfgManager: manager, Runner: runner, RateLimitDiscoverer: discoverer},\n\t\tmanager.GetAccountChannels(response.AccountUID),\n\t\t&AutoAddRateLimitHint{InitialRPM: 30, EffectiveRPM: 15, RateLimited: true, RateLimitedCount: 1},\n\t\t\"sk-test\",\n\t\t\"https://example.com\",\n\t)\n\tprofile := store.Get(endpointUID)\n\tif profile == nil || profile.DiscoveredRPM != 15 || profile.RateLimitConfidence != 0.7 || profile.RateLimitSource != string(RateLimitSourcePassiveAIMD) {\n\t\tt.Fatalf(\"rate limit profile=%+v\", profile)\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyToEquivalentExistingBaseURL(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [],\n \"chatUpstream\": [{\"channelUid\":\"ch_existing\",\"name\":\"localhost-old-name\",\"serviceType\":\"openai\",\"baseUrl\":\"http://localhost:8990/v1\",\"apiKeys\":[\"sk-old\"],\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"localhost-8990\",\"baseUrls\":[\"http://localhost:8990/\"],\"apiKeys\":[\"sk-new\"]}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 || response.Channels[0].Name != \"localhost-old-name\" {\n\t\tt.Fatalf(\"应返回已有渠道: %+v\", response.Channels)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 0 || len(cfg.ChatUpstream) != 1 {\n\t\tt.Fatalf(\"重复请求不应创建新渠道: messages=%d chat=%d\", len(cfg.Upstream), len(cfg.ChatUpstream))\n\t}\n\tif got := strings.Join(cfg.ChatUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"已有渠道密钥=%q, want sk-old,sk-new\", got)\n\t}\n}\n\nfunc TestFindExistingAutoAddChannelsPreservesHashSemantics(t *testing.T) {\n\tcfg := config.Config{\n\t\tChatUpstream: []config.UpstreamConfig{{\n\t\t\tChannelUID: \"ch-hash\", ServiceType: \"openai\", BaseURL: \"https://example.com#\",\n\t\t}},\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com\"}); len(matches) != 0 {\n\t\tt.Fatalf(\"普通 Base URL 不应匹配禁止版本前缀的地址: %+v\", matches)\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com/#\"}); len(matches) != 1 {\n\t\tt.Fatalf(\"等效 # 地址匹配数量=%d, want 1\", len(matches))\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyAndMissingRouteToManagedAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [{\"accountUid\":\"acct_existing\",\"channelUid\":\"ch_existing\",\"name\":\"localhost-8990\",\"serviceType\":\"claude\",\"baseUrl\":\"http://localhost:8990\",\"apiKeys\":[\"sk-old\"],\"autoManaged\":true,\"status\":\"active\"}],\n \"chatUpstream\": [], \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"localhost-8990\",\n \"baseUrls\":[\"http://localhost:8990/v1\"],\n \"apiKeys\":[\"sk-new\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"claude-test\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"gpt-test\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d\", len(cfg.Upstream), len(cfg.ResponsesUpstream))\n\t}\n\tif cfg.Upstream[0].AccountUID != \"acct_existing\" || cfg.ResponsesUpstream[0].AccountUID != \"acct_existing\" {\n\t\tt.Fatalf(\"应复用已有账号: messages=%q responses=%q\", cfg.Upstream[0].AccountUID, cfg.ResponsesUpstream[0].AccountUID)\n\t}\n\tif got := strings.Join(cfg.Upstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"messages 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif got := strings.Join(cfg.ResponsesUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"responses 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif cfg.Upstream[0].Name != \"localhost-8990-claude\" || cfg.ResponsesUpstream[0].Name != \"localhost-8990-codex\" {\n\t\tt.Fatalf(\"多协议自动命名错误: messages=%q responses=%q\", cfg.Upstream[0].Name, cfg.ResponsesUpstream[0].Name)\n\t}\n}\n\nfunc TestCustomAutoAddCreatesAllDetectedRoutesWithProtocolModels(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"fastaitoken-com-test\",\n \"baseUrls\":[\"https://example.com/keys\"],\n \"apiKeys\":[\"sk-test\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"shared\",\"messages-only\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"shared\",\"responses-only\"]},\n {\"channelKind\":\"chat\",\"supportedModels\":[\"shared\",\"chat-only\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 3 {\n\t\tt.Fatalf(\"channels=%+v, want three detected routes\", response.Channels)\n\t}\n\tfor _, route := range response.Channels {\n\t\tif route.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route account=%q, want %q\", route.AccountUID, response.AccountUID)\n\t\t}\n\t}\n\tif response.Channels[1].ChannelKind != \"responses\" || response.ChannelUID != response.Channels[1].ChannelUID {\n\t\tt.Fatalf(\"请求协议应作为主路由: response=%+v\", response)\n\t}\n\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 || len(cfg.ChatUpstream) != 1 || len(cfg.GeminiUpstream) != 0 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d chat=%d gemini=%d\",\n\t\t\tlen(cfg.Upstream), len(cfg.ResponsesUpstream), len(cfg.ChatUpstream), len(cfg.GeminiUpstream))\n\t}\n\tchecks := []struct {\n\t\tchannel config.UpstreamConfig\n\t\tname string\n\t\tmodels []string\n\t}{\n\t\t{cfg.Upstream[0], \"fastaitoken-com-test-claude\", []string{\"shared\", \"messages-only\"}},\n\t\t{cfg.ResponsesUpstream[0], \"fastaitoken-com-test-codex\", []string{\"shared\", \"responses-only\"}},\n\t\t{cfg.ChatUpstream[0], \"fastaitoken-com-test-chat\", []string{\"shared\", \"chat-only\"}},\n\t}\n\tfor _, check := range checks {\n\t\tif check.channel.Name != check.name || check.channel.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route identity=%+v, want name=%q account=%q\", check.channel, check.name, response.AccountUID)\n\t\t}\n\t\tif strings.Join(check.channel.SupportedModels, \",\") != strings.Join(check.models, \",\") {\n\t\t\tt.Fatalf(\"route %s models=%v, want %v\", check.name, check.channel.SupportedModels, check.models)\n\t\t}\n\t}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesRenamesAndSyncsCredentials(t *testing.T) {\n\tchannels := []config.AccountChannel{\n\t\t{Kind: \"messages\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-messages\", Name: \"old-claude\",\n\t\t\tServiceType: \"claude\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t\t{Kind: \"responses\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-responses\", Name: \"old-codex\",\n\t\t\tServiceType: \"responses\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t}\n\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\tName: \"renamed\", APIKeys: []string{\"sk-old\", \"sk-new\"},\n\t}, channels, len(channels))\n\tif err != nil || status != http.StatusOK {\n\t\tt.Fatalf(\"status=%d err=%v\", status, err)\n\t}\n\tif len(updates) != 2 || updates[0].Name != \"renamed-claude\" || updates[1].Name != \"renamed-codex\" {\n\t\tt.Fatalf(\"route names=%+v\", updates)\n\t}\n\tfor _, update := range updates {\n\t\tif len(update.APIKeyConfig) != 2 || update.APIKeyConfig[1].BaseURL != \"https://example.com\" {\n\t\t\tt.Fatalf(\"route credentials=%+v\", update.APIKeyConfig)\n\t\t}\n\t\tif update.APIKeyConfig[1].CredentialUID == \"\" {\n\t\t\tt.Fatalf(\"new credential missing stable uid: %+v\", update.APIKeyConfig[1])\n\t\t}\n\t}\n}\n\nfunc TestProviderRouteNameAndPrimaryResult(t *testing.T) {\n\tbase := \"mimo-test\"\n\ttests := []struct {\n\t\troute config.ProviderRoute\n\t\twant string\n\t}{\n\t\t{route: config.ProviderRoute{ChannelKind: \"messages\"}, want: \"mimo-test-claude\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"chat\"}, want: \"mimo-test-chat\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"responses\"}, want: \"mimo-test-codex\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"gemini\"}, want: \"mimo-test-gemini\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := providerRouteName(base, tt.route, true); got != tt.want {\n\t\t\tt.Fatalf(\"providerRouteName(%s)=%q, want %q\", tt.route.ChannelKind, got, tt.want)\n\t\t}\n\t}\n\tif got := providerRouteName(base, config.ProviderRoute{ChannelKind: \"messages\"}, false); got != base {\n\t\tt.Fatalf(\"single route name=%q, want %q\", got, base)\n\t}\n\n\tresults := []AutoAddChannelResult{\n\t\t{ChannelKind: \"messages\", ChannelUID: \"ch_messages\", Index: 1},\n\t\t{ChannelKind: \"chat\", ChannelUID: \"ch_chat\", Index: 2},\n\t\t{ChannelKind: \"responses\", ChannelUID: \"ch_responses\", Index: 3},\n\t}\n\tprimary := primaryAutoAddResult(results, \"chat\")\n\tif primary.ChannelUID != \"ch_chat\" || primary.Index != 2 {\n\t\tt.Fatalf(\"primary=%+v, want chat result\", primary)\n\t}\n}\n\nfunc TestManagedAccountUIDForProviderUsesExistingAccount(t *testing.T) {\n\tcfg := config.Config{ManagedAccounts: []config.ManagedAccountConfig{\n\t\t{AccountUID: \"acct-old\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-new\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-deepseek\", ProviderID: \"deepseek\"},\n\t}}\n\tif got := managedAccountUIDForProvider(cfg, \"mimo\"); got != \"acct-new\" {\n\t\tt.Fatalf(\"mimo existing account = %q, want acct-new\", got)\n\t}\n\tif got := managedAccountUIDForProvider(cfg, \"unknown\"); got != \"\" {\n\t\tt.Fatalf(\"unknown provider 不应命中账号: %q\", got)\n\t}\n}\n\nfunc TestListManagedAccountsDoesNotExposeVolcengineSecret(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-secret\",\"volcengineAccessKey\":{\"accessKeyId\":\"AKIDEXAMPLE\",\"secretAccessKey\":\"SECRET_MUST_NOT_LEAK\",\"plan\":\"agent_plan\",\"planTier\":\"Large\",\"planStatus\":\"Running\"}}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"SECRET_MUST_NOT_LEAK\") || strings.Contains(body, \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", body)\n\t}\n\tfor _, expected := range []string{`\"hasVolcengineAccessKey\":true`, `\"volcenginePlan\":\"agent_plan\"`, `\"volcenginePlanTier\":\"Large\"`} {\n\t\tif !strings.Contains(body, expected) {\n\t\t\tt.Fatalf(\"响应缺少 %s: %s\", expected, body)\n\t\t}\n\t}\n}\n\nfunc TestKimiConsoleTokenLifecycleDoesNotExposeToken(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_kimi\",\"providerId\":\"kimi\",\"name\":\"kimi\",\"credentials\":[{\"credentialUid\":\"cred_kimi\",\"apiKey\":\"sk-kimi-secret\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_kimi\",\"channelUid\":\"ch_kimi\",\"providerId\":\"kimi\",\"name\":\"kimi\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.kimi.com/coding\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_kimi\",\"baseUrl\":\"https://api.kimi.com/coding\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tkimiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Authorization\") != \"Bearer web-session-secret\" {\n\t\t\tt.Fatalf(\"Kimi 请求未使用保存的会话令牌\")\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase kimiUsagesPath:\n\t\t\t_, _ = w.Write([]byte(`{\"usages\":[{\"scope\":\"FEATURE_CODING\",\"detail\":{\"limit\":\"100\",\"remaining\":\"80\",\"resetTime\":\"2026-07-27T16:13:43Z\"},\"limits\":[{\"window\":{\"duration\":300,\"timeUnit\":\"TIME_UNIT_MINUTE\"},\"detail\":{\"limit\":\"100\",\"remaining\":\"90\",\"resetTime\":\"2026-07-21T02:13:43Z\"}}]}],\"totalQuota\":{\"limit\":\"100\",\"remaining\":\"80\"}}`))\n\t\tcase kimiSubscriptionStatsPath:\n\t\t\t_, _ = w.Write([]byte(`{\"ratelimitCode5h\":{\"ratio\":0.1,\"enabled\":true},\"ratelimitCode7d\":{\"ratio\":0.2,\"enabled\":true},\"subscriptionBalance\":{\"feature\":\"FEATURE_OMNI\",\"type\":\"SUBSCRIPTION\",\"unit\":\"UNIT_CREDIT\",\"amountUsedRatio\":0.2,\"kimiCodeUsedRatio\":0.2,\"expireTime\":\"2026-08-20T16:14:07Z\"}}`))\n\t\tdefault:\n\t\t\thttp.NotFound(w, r)\n\t\t}\n\t}))\n\tdefer kimiServer.Close()\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: cfgManager,\n\t\tKimiConsoleClient: &KimiConsoleClient{\n\t\t\tHTTPClient: kimiServer.Client(), BaseURL: kimiServer.URL,\n\t\t},\n\t})\n\tput := httptest.NewRequest(\n\t\thttp.MethodPut,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token\",\n\t\tbytes.NewBufferString(`{\"accessToken\":\"Bearer web-session-secret\"}`),\n\t)\n\tput.Header.Set(\"Content-Type\", \"application/json\")\n\tputRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(putRecorder, put)\n\tif putRecorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"绑定 Kimi 令牌失败: status=%d body=%s\", putRecorder.Code, putRecorder.Body.String())\n\t}\n\tif strings.Contains(putRecorder.Body.String(), \"web-session-secret\") || strings.Contains(putRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"绑定响应泄露 Kimi 令牌: %s\", putRecorder.Body.String())\n\t}\n\tcredential, ok := cfgManager.GetManagedAccountCredential(\"acct_kimi\", \"cred_kimi\")\n\tif !ok || credential.KimiConsole == nil || credential.KimiConsole.AccessToken != \"web-session-secret\" {\n\t\tt.Fatalf(\"Kimi 令牌未持久化: %+v\", credential)\n\t}\n\tpersisted, err := os.ReadFile(configPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !strings.Contains(string(persisted), `\"accessToken\": \"web-session-secret\"`) {\n\t\tt.Fatalf(\"配置文件未保存 Kimi 令牌\")\n\t}\n\n\tlistRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(listRecorder, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif listRecorder.Code != http.StatusOK || strings.Contains(listRecorder.Body.String(), \"web-session-secret\") || strings.Contains(listRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"账号列表响应异常或泄露令牌: status=%d body=%s\", listRecorder.Code, listRecorder.Body.String())\n\t}\n\tif !strings.Contains(listRecorder.Body.String(), `\"hasKimiConsoleToken\":true`) || !strings.Contains(listRecorder.Body.String(), `\"kimiCodeUsage\"`) {\n\t\tt.Fatalf(\"账号列表缺少 Kimi 用量快照: %s\", listRecorder.Body.String())\n\t}\n\n\trefreshRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(refreshRecorder, httptest.NewRequest(\n\t\thttp.MethodPost,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token/refresh\",\n\t\tnil,\n\t))\n\tif refreshRecorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"刷新 Kimi 余量失败: status=%d body=%s\", refreshRecorder.Code, refreshRecorder.Body.String())\n\t}\n\tif strings.Contains(refreshRecorder.Body.String(), \"web-session-secret\") || strings.Contains(refreshRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"刷新响应泄露 Kimi 令牌: %s\", refreshRecorder.Body.String())\n\t}\n\n\tdeleteRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(deleteRecorder, httptest.NewRequest(\n\t\thttp.MethodDelete,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token\",\n\t\tnil,\n\t))\n\tif deleteRecorder.Code != http.StatusNoContent {\n\t\tt.Fatalf(\"解绑 Kimi 令牌失败: status=%d body=%s\", deleteRecorder.Code, deleteRecorder.Body.String())\n\t}\n\tcredential, _ = cfgManager.GetManagedAccountCredential(\"acct_kimi\", \"cred_kimi\")\n\tif credential.KimiConsole != nil {\n\t\tt.Fatalf(\"Kimi 令牌解绑后仍存在: %+v\", credential.KimiConsole)\n\t}\n\tpersisted, err = os.ReadFile(configPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(persisted), \"web-session-secret\") {\n\t\tt.Fatalf(\"解绑后配置文件仍包含 Kimi 令牌\")\n\t}\n}\n\nfunc TestSetVolcengineAccessKeyDetectsPlanBeforePersisting(t *testing.T) {\n\tcontrolPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Query().Get(\"Action\") != \"GetPersonalPlan\" {\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tvar body struct{ Plan string }\n\t\t_ = json.NewDecoder(r.Body).Decode(&body)\n\t\tif body.Plan == \"CodingPlan\" {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t_, _ = w.Write([]byte(`{\"ResponseMetadata\":{\"Error\":{\"Code\":\"ResourceNotFound.Plan\",\"Message\":\"not found\"}}}`))\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\"Result\":{\"PlanType\":\"Large\",\"Status\":\"Running\"}}`))\n\t}))\n\tdefer controlPlane.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-inference\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trunner := NewAutoDiscoveryRunner(nil, nil)\n\trunner.client = controlPlane.Client()\n\trunner.volcengineControlPlaneEndpoint = controlPlane.URL\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, Runner: runner})\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_volc/credentials/cred_volc/volcengine-access-key\", bytes.NewBufferString(`{\"accessKeyId\":\"AKID\",\"secretAccessKey\":\"SECRET\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"plan\":\"agent_plan\"`) {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_volc\", \"cred_volc\")\n\tif !ok || credential.VolcengineAccessKey == nil || credential.VolcengineAccessKey.SecretAccessKey != \"SECRET\" || credential.VolcengineAccessKey.Plan != \"agent_plan\" {\n\t\tt.Fatalf(\"凭证未正确持久化: %+v\", credential)\n\t}\n}\n\nfunc TestSetMiMoConsoleCookieRequiresConfirmationBeforeAdoptingKey(t *testing.T) {\n\tconst oldKey = \"tp-old-1234567890123456789012345678901234567890\"\n\tconst cookieKey = \"tp-new-1234567890123456789012345678901234567890\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != \"api-platform_serviceToken=session; userId=42\" {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase \"/api/v1/userProfile\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"userId\":\"42\"}}`))\n\t\tcase \"/api/v1/tokenPlan/detail\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"planCode\":\"max\",\"planName\":\"Max\",\"currentPeriodEnd\":\"2026-07-29 23:59:59\"}}`))\n\t\tcase \"/api/v1/tokenPlan/usage\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"monthUsage\":{\"percent\":0.25,\"items\":[{\"used\":25,\"limit\":100}]},\"usage\":{\"percent\":0.5,\"items\":[{\"used\":50,\"limit\":100}]}}}`))\n\t\tcase \"/api/v1/tokenPlan/apiKey/raw\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":\"` + cookieKey + `\"}`))\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}\n\t}))\n\tdefer console.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"credentials\":[{\"credentialUid\":\"cred_mimo\",\"apiKey\":\"` + oldKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_mimo\",\"channelUid\":\"ch_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_mimo\",\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\tdeps := &AutoManagedDeps{CfgManager: manager, MiMoConsoleClient: &MiMoConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL}}\n\trouter := setupAutoManagedRouter(deps)\n\tbody := `{\"cookie\":\"api-platform_serviceToken=session; userId=42\"}`\n\trequest := func(payload string) *httptest.ResponseRecorder {\n\t\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_mimo/credentials/cred_mimo/mimo-console-cookie\", bytes.NewBufferString(payload))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tw := httptest.NewRecorder()\n\t\trouter.ServeHTTP(w, req)\n\t\treturn w\n\t}\n\tw := request(body)\n\tif w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), `\"code\":\"mimo_cookie_key_mismatch\"`) {\n\t\tt.Fatalf(\"未要求确认: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ := manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != oldKey || credential.MiMoConsole != nil {\n\t\tt.Fatalf(\"确认前不应修改配置: %+v\", credential)\n\t}\n\tw = request(`{\"cookie\":\"api-platform_serviceToken=session; userId=42\",\"adoptCookieKey\":true}`)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"keyAdopted\":true`) {\n\t\tt.Fatalf(\"确认采用失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ = manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != cookieKey || credential.MiMoConsole == nil {\n\t\tt.Fatalf(\"确认后未采用 Cookie Key: %+v\", credential)\n\t}\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif strings.Contains(list.Body.String(), \"api-platform_serviceToken\") || strings.Contains(list.Body.String(), cookieKey) {\n\t\tt.Fatalf(\"账号列表泄漏 Cookie 或原始 Key: %s\", list.Body.String())\n\t}\n}\n\nfunc TestCompshareConsoleCookieBindingDoesNotExposeSecrets(t *testing.T) {\n\tconst currentKey = \"sk-cp-test-current\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != testCompshareCookie {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\n \"RetCode\": 0,\n \"UserPlans\": [{\n \"Code\": \"cppkg-test\", \"PlanCode\": \"cp-plan-test\", \"PlanName\": \"Pro\", \"DisplayName\": \"Pro 增强版\",\n \"LimitPer5h\": 3000, \"LimitPerWeek\": 7500, \"LimitPerMonth\": 19000,\n \"UsagePer5h\": 0, \"UsagePerWeek\": 100, \"UsagePerMonth\": 6496,\n \"ConcurrencyLimit\": 10, \"Status\": 1, \"ExpireAt\": 1785037981,\n \"Keys\": [{\"APIKey\": \"` + currentKey + `\"}]\n }]\n}`))\n\t}))\n\tdefer console.Close()\n\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_compshare\",\"providerId\":\"compshare\",\"name\":\"compshare\",\"credentials\":[{\"credentialUid\":\"cred_compshare\",\"apiKey\":\"` + currentKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_compshare\",\"channelUid\":\"ch_compshare\",\"providerId\":\"compshare\",\"name\":\"compshare\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://api.compshare.cn/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_compshare\",\"baseUrl\":\"https://api.compshare.cn/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: manager,\n\t\tCompshareConsoleClient: &CompshareConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL},\n\t})\n\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_compshare/credentials/cred_compshare/compshare-console-cookie\", bytes.NewBufferString(`{\"cookie\":\"`+testCompshareCookie+`\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"displayName\":\"Pro 增强版\"`) {\n\t\tt.Fatalf(\"绑定失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_compshare\", \"cred_compshare\")\n\tif !ok || credential.CompshareConsole == nil || credential.CompshareConsole.Cookie != testCompshareCookie {\n\t\tt.Fatalf(\"控制台套餐未持久化: %+v\", credential)\n\t}\n\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tresponse := list.Body.String()\n\tfor _, secret := range []string{\"U_JWT_TOKEN\", \"test-session\", \"test_user@console.compshare.cn\", \"org-test\", currentKey, \"cppkg-test\"} {\n\t\tif strings.Contains(response, secret) {\n\t\t\tt.Fatalf(\"账号列表泄漏敏感字段 %q: %s\", secret, response)\n\t\t}\n\t}\n\tfor _, expected := range []string{`\"hasCompshareConsoleCookie\":true`, `\"planCode\":\"cp-plan-test\"`, `\"monthlyUsage\":{\"used\":6496,\"limit\":19000`} {\n\t\tif !strings.Contains(response, expected) {\n\t\t\tt.Fatalf(\"账号列表缺少套餐字段 %s: %s\", expected, response)\n\t\t}\n\t}\n}\n\nfunc TestProviderAutoAddReusesExistingAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(`{\"providerId\":\"mimo\",\"apiKeys\":[\"sk-existing\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"追加已有 provider key status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 4 {\n\t\tt.Fatalf(\"旧账号应自动补齐模板中的协议渠道: %+v\", channels)\n\t}\n\tkinds := make(map[string]bool, len(channels))\n\tfor _, channel := range channels {\n\t\tkinds[channel.Kind] = true\n\t}\n\tfor _, kind := range []string{\"messages\", \"chat\", \"responses\", \"gemini\"} {\n\t\tif !kinds[kind] {\n\t\t\tt.Fatalf(\"旧账号缺少 %s route: %+v\", kind, channels)\n\t\t}\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.AccountUID != \"acct_test\" {\n\t\tt.Fatalf(\"响应未返回已有账号: body=%s err=%v\", w.Body.String(), err)\n\t}\n\tif len(response.Channels) != 4 {\n\t\tt.Fatalf(\"响应未返回补齐后的全部渠道: %+v\", response.Channels)\n\t}\n}\n\nfunc TestMissingProviderAccountRoutes(t *testing.T) {\n\ttmpl, ok := config.GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"缺少 deepseek provider 模板\")\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{ServiceType: \"claude\"},\n\t}}\n\tmissing := missingProviderAccountRoutes(tmpl, existing)\n\tif len(missing) != 2 || missing[0].ChannelKind != \"chat\" || missing[1].ChannelKind != \"responses\" {\n\t\tt.Fatalf(\"missing routes = %+v\", missing)\n\t}\n\tconfigs, baseURLs, err := bindProviderRouteKeys(tmpl, missing[0], []string{\"sk-existing\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(configs) != 1 || configs[0].BaseURL != \"https://api.deepseek.com\" || len(baseURLs) != 1 {\n\t\tt.Fatalf(\"route 绑定结果不正确: configs=%+v baseURLs=%v\", configs, baseURLs)\n\t}\n}\n\nfunc TestBindProviderRouteKeysPreservesExistingEndpointAffinity(t *testing.T) {\n\ttests := []struct {\n\t\tproviderID string\n\t\tapiKey string\n\t\texistingBaseURL string\n\t\ttargetKind string\n\t\twantBaseURL string\n\t}{\n\t\t{\n\t\t\tproviderID: \"mimo\", apiKey: \"tp-existing\",\n\t\t\texistingBaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\t\ttargetKind: \"chat\", wantBaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\",\n\t\t},\n\t\t{\n\t\t\tproviderID: \"volcengine\", apiKey: \"ark-existing\",\n\t\t\texistingBaseURL: \"https://ark.cn-beijing.volces.com/api/coding\",\n\t\t\ttargetKind: \"responses\", wantBaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.providerID, func(t *testing.T) {\n\t\t\ttmpl, ok := config.GetProviderTemplate(tt.providerID)\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"缺少 provider 模板: %s\", tt.providerID)\n\t\t\t}\n\t\t\texisting := []config.AccountChannel{{\n\t\t\t\tKind: \"messages\",\n\t\t\t\tUpstream: config.UpstreamConfig{\n\t\t\t\t\tProviderID: tt.providerID, ServiceType: \"claude\", APIKeys: []string{tt.apiKey},\n\t\t\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: tt.apiKey, BaseURL: tt.existingBaseURL}},\n\t\t\t\t},\n\t\t\t}}\n\t\t\tvar target config.ProviderRoute\n\t\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\t\tif route.ChannelKind == tt.targetKind {\n\t\t\t\t\ttarget = route\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tconfigs, _, err := bindProviderRouteKeysWithAffinities(\n\t\t\t\ttmpl, target, []string{tt.apiKey}, providerKeyCandidateAffinities(tmpl, existing),\n\t\t\t)\n\t\t\tif err != nil || len(configs) != 1 || configs[0].BaseURL != tt.wantBaseURL {\n\t\t\t\tt.Fatalf(\"亲和绑定错误: configs=%+v err=%v want=%s\", configs, err, tt.wantBaseURL)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPlanProviderAccountRouteAdditionsRejectsNewKeyBeforeMutation(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t}))\n\tdefer server.Close()\n\n\ttmpl := &config.ProviderTemplate{\n\t\tProviderID: \"test-provider\",\n\t\tRoutes: []config.ProviderRoute{\n\t\t\t{ChannelKind: \"messages\", ServiceType: \"claude\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/anthropic\"}}},\n\t\t\t{ChannelKind: \"chat\", ServiceType: \"openai\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/v1\"}}},\n\t\t},\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct_test\", ChannelUID: \"ch_messages\", Name: \"test-provider\",\n\t\t\tProviderID: \"test-provider\", ServiceType: \"claude\", AutoManaged: true, APIKeys: []string{\"sk-existing\"},\n\t\t},\n\t}}\n\tadditions, status, err := planProviderAccountRouteAdditions(\n\t\tt.Context(), config.Config{}, tmpl, \"acct_test\", []string{\"sk-existing\", \"sk-invalid\"}, existing,\n\t)\n\tif err == nil || status != http.StatusBadRequest || len(additions) != 0 {\n\t\tt.Fatalf(\"无效新 Key 应在生成新增 route 前失败: status=%d additions=%+v err=%v\", status, additions, err)\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}, "8c7474e07f1bd849948dcbfaf327c9ec4611b965": {"t": 1785759336.8852725, "n": 58963, "h": "7404ca430f0339ef", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/BenedictKing/ccx/internal/errutil\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestListAccountsMasksCredentials(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-secret-value\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil)\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"sk-secret-value\") {\n\t\tt.Fatalf(\"账号列表泄露明文 Key: %s\", body)\n\t}\n\tif !strings.Contains(body, \"cred_test\") || !strings.Contains(body, \"keyMask\") {\n\t\tt.Fatalf(\"账号列表缺少凭证掩码信息: %s\", body)\n\t}\n}\n\nfunc TestListAccountsIncludesActiveEndpointModelAvailability(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"volcengine\",\"name\":\"volcengine\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"ark-secret-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"ark-secret-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"volcengine\",\"name\":\"volcengine-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://example.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tstore, err := NewProfileStoreWithDB(newTestDB(t))\n\tif err != nil {\n\t\tt.Fatalf(\"NewProfileStoreWithDB 失败: %v\", err)\n\t}\n\tupdatedAt := time.Date(2026, 7, 17, 10, 54, 33, 0, time.UTC)\n\tdiscoveredAt := updatedAt\n\tdiscoveredAtLater := updatedAt.Add(time.Minute)\n\tfor _, profile := range []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_a\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_a\", KeyMask: \"ark-a***001\", AvailableModels: []string{\"model-b\", \"model-a\"}, UpdatedAt: updatedAt,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI, ModelsDiscoveredAt: &discoveredAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_b\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_b\", KeyMask: \"ark-b***002\", AvailableModels: []string{\"model-c\", \"model-b\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI, ModelsDiscoveredAt: &discoveredAtLater,\n\t\t},\n\t} {\n\t\tif err := store.Upsert(profile); err != nil {\n\t\t\tt.Fatalf(\"ProfileStore.Upsert 失败: %v\", err)\n\t\t}\n\t}\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager, Runner: runner})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response struct {\n\t\tAccounts []managedAccountView `json:\"accounts\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatalf(\"解析响应失败: %v\", err)\n\t}\n\tif len(response.Accounts) != 1 || len(response.Accounts[0].Channels) != 1 {\n\t\tt.Fatalf(\"账号渠道数量错误: %+v\", response.Accounts)\n\t}\n\tchannel := response.Accounts[0].Channels[0]\n\tif got, want := strings.Join(channel.DiscoveredModels, \",\"), \"model-a,model-b,model-c\"; got != want {\n\t\tt.Fatalf(\"DiscoveredModels=%q, want %q\", got, want)\n\t}\n\tif !channel.ModelInventoryKnown {\n\t\tt.Fatal(\"已发现模型时 ModelInventoryKnown 应为 true\")\n\t}\n\tif len(channel.ModelBindings) != 2 {\n\t\tt.Fatalf(\"ModelBindings=%+v, want 2\", channel.ModelBindings)\n\t}\n\tif got := strings.Join(channel.ModelBindings[0].Models, \",\"); got != \"model-a,model-b\" {\n\t\tt.Fatalf(\"第一个 Key 模型=%q\", got)\n\t}\n\tif channel.ModelsUpdatedAt == nil || !channel.ModelsUpdatedAt.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"ModelsUpdatedAt=%v\", channel.ModelsUpdatedAt)\n\t}\n\tif strings.Contains(w.Body.String(), \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", w.Body.String())\n\t}\n}\n\nfunc TestListAccountsIncludesCustomAutoManagedProtocolAvailability(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [],\n \"upstream\": [], \"chatUpstream\": [],\n \"responsesUpstream\": [{\"accountUid\":\"acct_custom\",\"channelUid\":\"ch_custom\",\"name\":\"custom-endpoint\",\"serviceType\":\"responses\",\"baseUrl\":\"https://example.com\",\"apiKeys\":[\"sk-custom-secret\"],\"apiKeyConfigs\":[{\"key\":\"sk-custom-secret\",\"credentialUid\":\"cred_custom\",\"baseUrl\":\"https://example.com\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tstore, err := NewProfileStoreWithDB(newTestDB(t))\n\tif err != nil {\n\t\tt.Fatalf(\"NewProfileStoreWithDB 失败: %v\", err)\n\t}\n\tdiscoveredAt := time.Date(2026, 7, 25, 5, 11, 29, 0, time.UTC)\n\tprofile := &KeyEndpointProfile{\n\t\tEndpointUID: \"ep_custom\", AccountUID: \"acct_custom\", ChannelUID: \"ch_custom\",\n\t\tCredentialUID: \"cred_custom\", KeyMask: \"sk-c***ret\", ServiceType: \"responses\",\n\t\tAvailableModels: []string{\"gpt-5.6-sol\"}, ModelsDiscoveredAt: &discoveredAt,\n\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI,\n\t\tProtocolModels: map[string][]string{\n\t\t\t\"messages\": {\"gpt-5.6-sol\"},\n\t\t\t\"chat\": {\"gpt-5.6-sol\"},\n\t\t\t\"responses\": {\"gpt-5.6-sol\"},\n\t\t},\n\t\tProtocolDiscoveredAt: map[string]time.Time{\n\t\t\t\"messages\": discoveredAt, \"chat\": discoveredAt, \"responses\": discoveredAt,\n\t\t},\n\t\tProtocolDiscoverySource: map[string]string{\n\t\t\t\"messages\": \"protocol_probe\", \"chat\": \"protocol_probe\", \"responses\": \"models_api\",\n\t\t},\n\t\tUpdatedAt: discoveredAt,\n\t}\n\tif err := store.Upsert(profile); err != nil {\n\t\tt.Fatalf(\"ProfileStore.Upsert 失败: %v\", err)\n\t}\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: cfgManager,\n\t\tRunner: NewAutoDiscoveryRunner(store, nil),\n\t})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tif strings.Contains(w.Body.String(), \"sk-custom-secret\") {\n\t\tt.Fatalf(\"账号列表泄露自定义渠道明文 Key: %s\", w.Body.String())\n\t}\n\n\tvar response struct {\n\t\tAccounts []managedAccountView `json:\"accounts\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatalf(\"解析响应失败: %v\", err)\n\t}\n\tif len(response.Accounts) != 1 {\n\t\tt.Fatalf(\"Accounts=%+v, want 1\", response.Accounts)\n\t}\n\taccount := response.Accounts[0]\n\tif account.AccountUID != \"acct_custom\" || len(account.Credentials) != 1 || len(account.Channels) != 1 {\n\t\tt.Fatalf(\"自定义自动托管账号聚合错误: %+v\", account)\n\t}\n\tprotocols := make([]string, 0, len(account.Channels[0].ProtocolAvailability))\n\tfor _, availability := range account.Channels[0].ProtocolAvailability {\n\t\tprotocols = append(protocols, availability.Protocol)\n\t}\n\tif got, want := strings.Join(protocols, \",\"), \"messages,chat,responses\"; got != want {\n\t\tt.Fatalf(\"ProtocolAvailability=%q, want %q\", got, want)\n\t}\n}\n\nfunc TestManagedChannelModelAvailabilityIncludesEmptyAutoDiscoveryResult(t *testing.T) {\n\tupdatedAt := time.Date(2026, 7, 17, 11, 2, 0, 0, time.UTC)\n\tmodels, bindings, latest, known := managedChannelModelAvailability([]*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_empty\", CredentialUID: \"cred_empty\", KeyMask: \"ark-e***001\",\n\t\t\tSource: \"auto_discovery\", AvailableModels: []string{}, UpdatedAt: updatedAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_models\", CredentialUID: \"cred_models\", KeyMask: \"ark-m***002\",\n\t\t\tAvailableModels: []string{\"model-a\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_unknown\", CredentialUID: \"cred_unknown\", KeyMask: \"ark-u***003\",\n\t\t\tSource: \"l1_passive\", UpdatedAt: updatedAt.Add(2 * time.Minute),\n\t\t},\n\t})\n\n\tif !known {\n\t\tt.Fatal(\"自动发现的空模型清单仍应标记为已知\")\n\t}\n\tif got, want := strings.Join(models, \",\"), \"model-a\"; got != want {\n\t\tt.Fatalf(\"models=%q, want %q\", got, want)\n\t}\n\tif len(bindings) != 2 {\n\t\tt.Fatalf(\"bindings=%+v, want only two known inventories\", bindings)\n\t}\n\tif got := bindings[0]; got.KeyMask != \"ark-e***001\" || len(got.Models) != 0 {\n\t\tt.Fatalf(\"empty discovery binding=%+v, want empty ark-e binding\", got)\n\t}\n\tif latest == nil || !latest.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"latest=%v, want discovered inventory update time\", latest)\n\t}\n}\n\nfunc TestManagedChannelModelAvailabilityIncludesDiscoveryMetadata(t *testing.T) {\n\tfirst := time.Date(2026, 7, 22, 0, 42, 12, 0, time.UTC)\n\tsecond := first.Add(time.Minute)\n\tinventory := managedChannelModelAvailabilityDetails([]*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep-a\",\n\t\t\tCredentialUID: \"cred-a\",\n\t\t\tKeyMask: \"ark-a***001\",\n\t\t\tAvailableModels: []string{\"glm-5.2\"},\n\t\t\tUpdatedAt: first,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceControlPlane,\n\t\t\tModelDiscoveryMessage: \"火山管控面 Coding Plan 模型清单\",\n\t\t\tModelsDiscoveredAt: &first,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep-b\",\n\t\t\tCredentialUID: \"cred-b\",\n\t\t\tKeyMask: \"ark-b***002\",\n\t\t\tAvailableModels: []string{\"deepseek-v4-pro\"},\n\t\t\tUpdatedAt: second,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceBuiltinFallback,\n\t\t\tModelDiscoveryMessage: \"管控面失败,已回退内置模型清单\",\n\t\t\tModelsDiscoveredAt: &second,\n\t\t},\n\t})\n\n\tif !inventory.known || inventory.source != \"mixed\" {\n\t\tt.Fatalf(\"inventory source=%q known=%v\", inventory.source, inventory.known)\n\t}\n\tif !inventory.latestDiscoveredAt.Equal(second) || inventory.message != \"管控面失败,已回退内置模型清单\" {\n\t\tt.Fatalf(\"inventory discovery metadata=%+v\", inventory)\n\t}\n\tif len(inventory.bindings) != 2 || inventory.bindings[1].ModelsDiscoveredAt == nil {\n\t\tt.Fatalf(\"binding discovery metadata=%+v\", inventory.bindings)\n\t}\n}\n\nfunc TestManagedProtocolAvailabilityViewsAggregatesByProtocolAndCredential(t *testing.T) {\n\tdiscoveredAt := time.Date(2026, 7, 25, 10, 0, 0, 0, time.UTC)\n\tprofiles := []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep-a\", CredentialUID: \"cred-a\", KeyMask: \"sk-a***001\", ServiceType: \"responses\",\n\t\t\tUpdatedAt: discoveredAt, ProtocolModels: map[string][]string{\n\t\t\t\t\"responses\": {\"shared\", \"responses-only\"},\n\t\t\t\t\"chat\": {\"shared\"},\n\t\t\t},\n\t\t\tProtocolDiscoveredAt: map[string]time.Time{\"responses\": discoveredAt, \"chat\": discoveredAt},\n\t\t\tProtocolDiscoverySource: map[string]string{\"responses\": \"models_api\", \"chat\": \"protocol_probe\"},\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep-b\", CredentialUID: \"cred-b\", KeyMask: \"sk-b***002\", ServiceType: \"responses\",\n\t\t\tUpdatedAt: discoveredAt, ProtocolModels: map[string][]string{\n\t\t\t\t\"responses\": {\"shared\"},\n\t\t\t\t\"messages\": {\"shared\", \"messages-only\"},\n\t\t\t},\n\t\t\tProtocolDiscoveredAt: map[string]time.Time{\"responses\": discoveredAt, \"messages\": discoveredAt},\n\t\t\tProtocolDiscoverySource: map[string]string{\"responses\": \"models_api\", \"messages\": \"protocol_probe\"},\n\t\t},\n\t}\n\n\tviews := managedProtocolAvailabilityViews(profiles)\n\tif len(views) != 3 {\n\t\tt.Fatalf(\"views=%+v\", views)\n\t}\n\tif views[0].Protocol != \"messages\" || strings.Join(views[0].DiscoveredModels, \",\") != \"messages-only,shared\" {\n\t\tt.Fatalf(\"messages view=%+v\", views[0])\n\t}\n\tif views[2].Protocol != \"responses\" || len(views[2].ModelBindings) != 2 {\n\t\tt.Fatalf(\"responses view=%+v\", views[2])\n\t}\n}\n\nfunc TestPatchAccountCredentialsRemovesByUID(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"sk-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"sk-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"baseUrls\":[\"https://api.xiaomimimo.com/anthropic\"],\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPatch, \"/api/accounts/acct_test/credentials\", bytes.NewBufferString(`{\"removeCredentialUids\":[\"cred_b\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PATCH credentials status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 1 || len(channels[0].Upstream.APIKeys) != 1 || channels[0].Upstream.APIKeys[0] != \"sk-a\" {\n\t\tt.Fatalf(\"按 credentialUid 删除失败: %+v\", channels)\n\t}\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestInferAutoAddProviderID(t *testing.T) {\n\tzhipuKey := \"0123456789abcdef0123456789abcdef.ABCDEFGHIJKLMNO1\"\n\ttests := []struct {\n\t\tname string\n\t\tbaseURLs []string\n\t\tapiKeys []string\n\t\twant string\n\t}{\n\t\t{name: \"官方 Claude URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"官方 OpenAI URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4/\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"两个官方协议 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\", \"https://open.bigmodel.cn/api/paas/v4\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"仅智谱 Key\", apiKeys: []string{zhipuKey}, want: \"glm\"},\n\t\t{name: \"第三方 URL 不按 Key 提升\", baseURLs: []string{\"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"混合官方与第三方 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4\", \"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"共享 sk Key 无法推断\", apiKeys: []string{\"sk-abcdefghijklmnopqrstuvwxyz123456\"}},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := inferAutoAddProviderID(tt.baseURLs, tt.apiKeys); got != tt.want {\n\t\t\t\tt.Fatalf(\"inferAutoAddProviderID() = %q, want %q\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestApplyProviderUpstreamDefaults(t *testing.T) {\n\tglmChat := config.UpstreamConfig{ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmChat)\n\tif glmChat.ReasoningParamStyle != \"reasoning_effort\" || !glmChat.PassbackReasoningContent {\n\t\tt.Fatalf(\"GLM OpenAI 默认兼容参数未补齐: %+v\", glmChat)\n\t}\n\n\tglmClaude := config.UpstreamConfig{ServiceType: \"claude\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmClaude)\n\tif glmClaude.ReasoningParamStyle != \"\" || glmClaude.PassbackReasoningContent {\n\t\tt.Fatalf(\"GLM Claude 原生 route 不应注入 OpenAI 参数: %+v\", glmClaude)\n\t}\n\n\tcustom := config.UpstreamConfig{ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"\", &custom)\n\tif custom.ReasoningParamStyle != \"\" || custom.PassbackReasoningContent {\n\t\tt.Fatalf(\"自定义渠道不应应用 GLM 默认值: %+v\", custom)\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCustomAutoAddResponseIncludesActualRoute(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\tdiscoverer := NewRateLimitDiscoverer(RateLimitDiscovererConfig{PassiveAimdEnabled: true, QuietLogs: true})\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, RateLimitDiscoverer: discoverer})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"fastaitoken-com-test\",\"baseUrls\":[\"https://example.com\"],\"apiKeys\":[\"sk-test\"],\"rateLimitHint\":{\"initialRpm\":30,\"effectiveRpm\":15,\"rateLimited\":true,\"rateLimitedCount\":1}}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 {\n\t\tt.Fatalf(\"channels=%+v, want one actual route\", response.Channels)\n\t}\n\troute := response.Channels[0]\n\tif route.ChannelKind != \"responses\" || route.ServiceType != \"responses\" || route.ChannelUID != response.ChannelUID {\n\t\tt.Fatalf(\"route=%+v response=%+v\", route, response)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.ResponsesUpstream) != 1 || len(cfg.Upstream) != 0 {\n\t\tt.Fatalf(\"custom route persisted in wrong channel: responses=%d messages=%d\", len(cfg.ResponsesUpstream), len(cfg.Upstream))\n\t}\n\tcreated := cfg.ResponsesUpstream[0]\n\tbaseURL := created.GetEffectiveBaseURL()\n\tendpointUID := GenerateEndpointUID(created.ChannelUID, baseURL, KeyHashFromAPIKey(\"sk-test\"))\n\tif suggestion := discoverer.SuggestedLimit(endpointUID); suggestion.RPM != 15 || suggestion.Confidence != 0.7 {\n\t\tt.Fatalf(\"rate limit suggestion=%+v, want rpm=15 confidence=0.7\", suggestion)\n\t}\n\n\tstore := newTestProfileStore(t)\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\tseedAutoAddRateLimitHint(\n\t\t&AutoManagedDeps{CfgManager: manager, Runner: runner, RateLimitDiscoverer: discoverer},\n\t\tmanager.GetAccountChannels(response.AccountUID),\n\t\t&AutoAddRateLimitHint{InitialRPM: 30, EffectiveRPM: 15, RateLimited: true, RateLimitedCount: 1},\n\t\t\"sk-test\",\n\t\t\"https://example.com\",\n\t)\n\tprofile := store.Get(endpointUID)\n\tif profile == nil || profile.DiscoveredRPM != 15 || profile.RateLimitConfidence != 0.7 || profile.RateLimitSource != string(RateLimitSourcePassiveAIMD) {\n\t\tt.Fatalf(\"rate limit profile=%+v\", profile)\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyToEquivalentExistingBaseURL(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [],\n \"chatUpstream\": [{\"channelUid\":\"ch_existing\",\"name\":\"localhost-old-name\",\"serviceType\":\"openai\",\"baseUrl\":\"http://localhost:8990/v1\",\"apiKeys\":[\"sk-old\"],\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"localhost-8990\",\"baseUrls\":[\"http://localhost:8990/\"],\"apiKeys\":[\"sk-new\"]}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 || response.Channels[0].Name != \"localhost-old-name\" {\n\t\tt.Fatalf(\"应返回已有渠道: %+v\", response.Channels)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 0 || len(cfg.ChatUpstream) != 1 {\n\t\tt.Fatalf(\"重复请求不应创建新渠道: messages=%d chat=%d\", len(cfg.Upstream), len(cfg.ChatUpstream))\n\t}\n\tif got := strings.Join(cfg.ChatUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"已有渠道密钥=%q, want sk-old,sk-new\", got)\n\t}\n}\n\nfunc TestFindExistingAutoAddChannelsPreservesHashSemantics(t *testing.T) {\n\tcfg := config.Config{\n\t\tChatUpstream: []config.UpstreamConfig{{\n\t\t\tChannelUID: \"ch-hash\", ServiceType: \"openai\", BaseURL: \"https://example.com#\",\n\t\t}},\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com\"}); len(matches) != 0 {\n\t\tt.Fatalf(\"普通 Base URL 不应匹配禁止版本前缀的地址: %+v\", matches)\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com/#\"}); len(matches) != 1 {\n\t\tt.Fatalf(\"等效 # 地址匹配数量=%d, want 1\", len(matches))\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyAndMissingRouteToManagedAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [{\"accountUid\":\"acct_existing\",\"channelUid\":\"ch_existing\",\"name\":\"localhost-8990\",\"serviceType\":\"claude\",\"baseUrl\":\"http://localhost:8990\",\"apiKeys\":[\"sk-old\"],\"autoManaged\":true,\"status\":\"active\"}],\n \"chatUpstream\": [], \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"localhost-8990\",\n \"baseUrls\":[\"http://localhost:8990/v1\"],\n \"apiKeys\":[\"sk-new\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"claude-test\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"gpt-test\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d\", len(cfg.Upstream), len(cfg.ResponsesUpstream))\n\t}\n\tif cfg.Upstream[0].AccountUID != \"acct_existing\" || cfg.ResponsesUpstream[0].AccountUID != \"acct_existing\" {\n\t\tt.Fatalf(\"应复用已有账号: messages=%q responses=%q\", cfg.Upstream[0].AccountUID, cfg.ResponsesUpstream[0].AccountUID)\n\t}\n\tif got := strings.Join(cfg.Upstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"messages 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif got := strings.Join(cfg.ResponsesUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"responses 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif cfg.Upstream[0].Name != \"localhost-8990-claude\" || cfg.ResponsesUpstream[0].Name != \"localhost-8990-codex\" {\n\t\tt.Fatalf(\"多协议自动命名错误: messages=%q responses=%q\", cfg.Upstream[0].Name, cfg.ResponsesUpstream[0].Name)\n\t}\n}\n\nfunc TestCustomAutoAddCreatesAllDetectedRoutesWithProtocolModels(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"fastaitoken-com-test\",\n \"baseUrls\":[\"https://example.com/keys\"],\n \"apiKeys\":[\"sk-test\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"shared\",\"messages-only\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"shared\",\"responses-only\"]},\n {\"channelKind\":\"chat\",\"supportedModels\":[\"shared\",\"chat-only\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 3 {\n\t\tt.Fatalf(\"channels=%+v, want three detected routes\", response.Channels)\n\t}\n\tfor _, route := range response.Channels {\n\t\tif route.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route account=%q, want %q\", route.AccountUID, response.AccountUID)\n\t\t}\n\t}\n\tif response.Channels[1].ChannelKind != \"responses\" || response.ChannelUID != response.Channels[1].ChannelUID {\n\t\tt.Fatalf(\"请求协议应作为主路由: response=%+v\", response)\n\t}\n\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 || len(cfg.ChatUpstream) != 1 || len(cfg.GeminiUpstream) != 0 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d chat=%d gemini=%d\",\n\t\t\tlen(cfg.Upstream), len(cfg.ResponsesUpstream), len(cfg.ChatUpstream), len(cfg.GeminiUpstream))\n\t}\n\tchecks := []struct {\n\t\tchannel config.UpstreamConfig\n\t\tname string\n\t\tmodels []string\n\t}{\n\t\t{cfg.Upstream[0], \"fastaitoken-com-test-claude\", []string{\"shared\", \"messages-only\"}},\n\t\t{cfg.ResponsesUpstream[0], \"fastaitoken-com-test-codex\", []string{\"shared\", \"responses-only\"}},\n\t\t{cfg.ChatUpstream[0], \"fastaitoken-com-test-chat\", []string{\"shared\", \"chat-only\"}},\n\t}\n\tfor _, check := range checks {\n\t\tif check.channel.Name != check.name || check.channel.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route identity=%+v, want name=%q account=%q\", check.channel, check.name, response.AccountUID)\n\t\t}\n\t\tif strings.Join(check.channel.SupportedModels, \",\") != strings.Join(check.models, \",\") {\n\t\t\tt.Fatalf(\"route %s models=%v, want %v\", check.name, check.channel.SupportedModels, check.models)\n\t\t}\n\t}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesRenamesAndSyncsCredentials(t *testing.T) {\n\tchannels := []config.AccountChannel{\n\t\t{Kind: \"messages\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-messages\", Name: \"old-claude\",\n\t\t\tServiceType: \"claude\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t\t{Kind: \"responses\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-responses\", Name: \"old-codex\",\n\t\t\tServiceType: \"responses\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t}\n\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\tName: \"renamed\", APIKeys: []string{\"sk-old\", \"sk-new\"},\n\t}, channels, len(channels))\n\tif err != nil || status != http.StatusOK {\n\t\tt.Fatalf(\"status=%d err=%v\", status, err)\n\t}\n\tif len(updates) != 2 || updates[0].Name != \"renamed-claude\" || updates[1].Name != \"renamed-codex\" {\n\t\tt.Fatalf(\"route names=%+v\", updates)\n\t}\n\tfor _, update := range updates {\n\t\tif len(update.APIKeyConfig) != 2 || update.APIKeyConfig[1].BaseURL != \"https://example.com\" {\n\t\t\tt.Fatalf(\"route credentials=%+v\", update.APIKeyConfig)\n\t\t}\n\t\tif update.APIKeyConfig[1].CredentialUID == \"\" {\n\t\t\tt.Fatalf(\"new credential missing stable uid: %+v\", update.APIKeyConfig[1])\n\t\t}\n\t}\n}\n\nfunc TestProviderRouteNameAndPrimaryResult(t *testing.T) {\n\tbase := \"mimo-test\"\n\ttests := []struct {\n\t\troute config.ProviderRoute\n\t\twant string\n\t}{\n\t\t{route: config.ProviderRoute{ChannelKind: \"messages\"}, want: \"mimo-test-claude\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"chat\"}, want: \"mimo-test-chat\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"responses\"}, want: \"mimo-test-codex\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"gemini\"}, want: \"mimo-test-gemini\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := providerRouteName(base, tt.route, true); got != tt.want {\n\t\t\tt.Fatalf(\"providerRouteName(%s)=%q, want %q\", tt.route.ChannelKind, got, tt.want)\n\t\t}\n\t}\n\tif got := providerRouteName(base, config.ProviderRoute{ChannelKind: \"messages\"}, false); got != base {\n\t\tt.Fatalf(\"single route name=%q, want %q\", got, base)\n\t}\n\n\tresults := []AutoAddChannelResult{\n\t\t{ChannelKind: \"messages\", ChannelUID: \"ch_messages\", Index: 1},\n\t\t{ChannelKind: \"chat\", ChannelUID: \"ch_chat\", Index: 2},\n\t\t{ChannelKind: \"responses\", ChannelUID: \"ch_responses\", Index: 3},\n\t}\n\tprimary := primaryAutoAddResult(results, \"chat\")\n\tif primary.ChannelUID != \"ch_chat\" || primary.Index != 2 {\n\t\tt.Fatalf(\"primary=%+v, want chat result\", primary)\n\t}\n}\n\nfunc TestManagedAccountUIDForProviderUsesExistingAccount(t *testing.T) {\n\tcfg := config.Config{ManagedAccounts: []config.ManagedAccountConfig{\n\t\t{AccountUID: \"acct-old\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-new\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-deepseek\", ProviderID: \"deepseek\"},\n\t}}\n\tif got := managedAccountUIDForProvider(cfg, \"mimo\"); got != \"acct-new\" {\n\t\tt.Fatalf(\"mimo existing account = %q, want acct-new\", got)\n\t}\n\tif got := managedAccountUIDForProvider(cfg, \"unknown\"); got != \"\" {\n\t\tt.Fatalf(\"unknown provider 不应命中账号: %q\", got)\n\t}\n}\n\nfunc TestListManagedAccountsDoesNotExposeVolcengineSecret(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-secret\",\"volcengineAccessKey\":{\"accessKeyId\":\"AKIDEXAMPLE\",\"secretAccessKey\":\"SECRET_MUST_NOT_LEAK\",\"plan\":\"agent_plan\",\"planTier\":\"Large\",\"planStatus\":\"Running\"}}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"SECRET_MUST_NOT_LEAK\") || strings.Contains(body, \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", body)\n\t}\n\tfor _, expected := range []string{`\"hasVolcengineAccessKey\":true`, `\"volcenginePlan\":\"agent_plan\"`, `\"volcenginePlanTier\":\"Large\"`} {\n\t\tif !strings.Contains(body, expected) {\n\t\t\tt.Fatalf(\"响应缺少 %s: %s\", expected, body)\n\t\t}\n\t}\n}\n\nfunc TestKimiConsoleTokenLifecycleDoesNotExposeToken(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_kimi\",\"providerId\":\"kimi\",\"name\":\"kimi\",\"credentials\":[{\"credentialUid\":\"cred_kimi\",\"apiKey\":\"sk-kimi-secret\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_kimi\",\"channelUid\":\"ch_kimi\",\"providerId\":\"kimi\",\"name\":\"kimi\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.kimi.com/coding\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_kimi\",\"baseUrl\":\"https://api.kimi.com/coding\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tkimiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Authorization\") != \"Bearer web-session-secret\" {\n\t\t\tt.Fatalf(\"Kimi 请求未使用保存的会话令牌\")\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase kimiUsagesPath:\n\t\t\t_, _ = w.Write([]byte(`{\"usages\":[{\"scope\":\"FEATURE_CODING\",\"detail\":{\"limit\":\"100\",\"remaining\":\"80\",\"resetTime\":\"2026-07-27T16:13:43Z\"},\"limits\":[{\"window\":{\"duration\":300,\"timeUnit\":\"TIME_UNIT_MINUTE\"},\"detail\":{\"limit\":\"100\",\"remaining\":\"90\",\"resetTime\":\"2026-07-21T02:13:43Z\"}}]}],\"totalQuota\":{\"limit\":\"100\",\"remaining\":\"80\"}}`))\n\t\tcase kimiSubscriptionStatsPath:\n\t\t\t_, _ = w.Write([]byte(`{\"ratelimitCode5h\":{\"ratio\":0.1,\"enabled\":true},\"ratelimitCode7d\":{\"ratio\":0.2,\"enabled\":true},\"subscriptionBalance\":{\"feature\":\"FEATURE_OMNI\",\"type\":\"SUBSCRIPTION\",\"unit\":\"UNIT_CREDIT\",\"amountUsedRatio\":0.2,\"kimiCodeUsedRatio\":0.2,\"expireTime\":\"2026-08-20T16:14:07Z\"}}`))\n\t\tdefault:\n\t\t\thttp.NotFound(w, r)\n\t\t}\n\t}))\n\tdefer kimiServer.Close()\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: cfgManager,\n\t\tKimiConsoleClient: &KimiConsoleClient{\n\t\t\tHTTPClient: kimiServer.Client(), BaseURL: kimiServer.URL,\n\t\t},\n\t})\n\tput := httptest.NewRequest(\n\t\thttp.MethodPut,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token\",\n\t\tbytes.NewBufferString(`{\"accessToken\":\"Bearer web-session-secret\"}`),\n\t)\n\tput.Header.Set(\"Content-Type\", \"application/json\")\n\tputRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(putRecorder, put)\n\tif putRecorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"绑定 Kimi 令牌失败: status=%d body=%s\", putRecorder.Code, putRecorder.Body.String())\n\t}\n\tif strings.Contains(putRecorder.Body.String(), \"web-session-secret\") || strings.Contains(putRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"绑定响应泄露 Kimi 令牌: %s\", putRecorder.Body.String())\n\t}\n\tcredential, ok := cfgManager.GetManagedAccountCredential(\"acct_kimi\", \"cred_kimi\")\n\tif !ok || credential.KimiConsole == nil || credential.KimiConsole.AccessToken != \"web-session-secret\" {\n\t\tt.Fatalf(\"Kimi 令牌未持久化: %+v\", credential)\n\t}\n\tpersisted, err := os.ReadFile(configPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !strings.Contains(string(persisted), `\"accessToken\": \"web-session-secret\"`) {\n\t\tt.Fatalf(\"配置文件未保存 Kimi 令牌\")\n\t}\n\n\tlistRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(listRecorder, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif listRecorder.Code != http.StatusOK || strings.Contains(listRecorder.Body.String(), \"web-session-secret\") || strings.Contains(listRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"账号列表响应异常或泄露令牌: status=%d body=%s\", listRecorder.Code, listRecorder.Body.String())\n\t}\n\tif !strings.Contains(listRecorder.Body.String(), `\"hasKimiConsoleToken\":true`) || !strings.Contains(listRecorder.Body.String(), `\"kimiCodeUsage\"`) {\n\t\tt.Fatalf(\"账号列表缺少 Kimi 用量快照: %s\", listRecorder.Body.String())\n\t}\n\n\trefreshRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(refreshRecorder, httptest.NewRequest(\n\t\thttp.MethodPost,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token/refresh\",\n\t\tnil,\n\t))\n\tif refreshRecorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"刷新 Kimi 余量失败: status=%d body=%s\", refreshRecorder.Code, refreshRecorder.Body.String())\n\t}\n\tif strings.Contains(refreshRecorder.Body.String(), \"web-session-secret\") || strings.Contains(refreshRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"刷新响应泄露 Kimi 令牌: %s\", refreshRecorder.Body.String())\n\t}\n\n\tdeleteRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(deleteRecorder, httptest.NewRequest(\n\t\thttp.MethodDelete,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token\",\n\t\tnil,\n\t))\n\tif deleteRecorder.Code != http.StatusNoContent {\n\t\tt.Fatalf(\"解绑 Kimi 令牌失败: status=%d body=%s\", deleteRecorder.Code, deleteRecorder.Body.String())\n\t}\n\tcredential, _ = cfgManager.GetManagedAccountCredential(\"acct_kimi\", \"cred_kimi\")\n\tif credential.KimiConsole != nil {\n\t\tt.Fatalf(\"Kimi 令牌解绑后仍存在: %+v\", credential.KimiConsole)\n\t}\n\tpersisted, err = os.ReadFile(configPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(persisted), \"web-session-secret\") {\n\t\tt.Fatalf(\"解绑后配置文件仍包含 Kimi 令牌\")\n\t}\n}\n\nfunc TestSetVolcengineAccessKeyDetectsPlanBeforePersisting(t *testing.T) {\n\tcontrolPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Query().Get(\"Action\") != \"GetPersonalPlan\" {\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tvar body struct{ Plan string }\n\t\t_ = json.NewDecoder(r.Body).Decode(&body)\n\t\tif body.Plan == \"CodingPlan\" {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t_, _ = w.Write([]byte(`{\"ResponseMetadata\":{\"Error\":{\"Code\":\"ResourceNotFound.Plan\",\"Message\":\"not found\"}}}`))\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\"Result\":{\"PlanType\":\"Large\",\"Status\":\"Running\"}}`))\n\t}))\n\tdefer controlPlane.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-inference\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trunner := NewAutoDiscoveryRunner(nil, nil)\n\trunner.client = controlPlane.Client()\n\trunner.volcengineControlPlaneEndpoint = controlPlane.URL\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, Runner: runner})\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_volc/credentials/cred_volc/volcengine-access-key\", bytes.NewBufferString(`{\"accessKeyId\":\"AKID\",\"secretAccessKey\":\"SECRET\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"plan\":\"agent_plan\"`) {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_volc\", \"cred_volc\")\n\tif !ok || credential.VolcengineAccessKey == nil || credential.VolcengineAccessKey.SecretAccessKey != \"SECRET\" || credential.VolcengineAccessKey.Plan != \"agent_plan\" {\n\t\tt.Fatalf(\"凭证未正确持久化: %+v\", credential)\n\t}\n}\n\nfunc TestSetMiMoConsoleCookieRequiresConfirmationBeforeAdoptingKey(t *testing.T) {\n\tconst oldKey = \"tp-old-1234567890123456789012345678901234567890\"\n\tconst cookieKey = \"tp-new-1234567890123456789012345678901234567890\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != \"api-platform_serviceToken=session; userId=42\" {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase \"/api/v1/userProfile\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"userId\":\"42\"}}`))\n\t\tcase \"/api/v1/tokenPlan/detail\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"planCode\":\"max\",\"planName\":\"Max\",\"currentPeriodEnd\":\"2026-07-29 23:59:59\"}}`))\n\t\tcase \"/api/v1/tokenPlan/usage\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"monthUsage\":{\"percent\":0.25,\"items\":[{\"used\":25,\"limit\":100}]},\"usage\":{\"percent\":0.5,\"items\":[{\"used\":50,\"limit\":100}]}}}`))\n\t\tcase \"/api/v1/tokenPlan/apiKey/raw\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":\"` + cookieKey + `\"}`))\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}\n\t}))\n\tdefer console.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"credentials\":[{\"credentialUid\":\"cred_mimo\",\"apiKey\":\"` + oldKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_mimo\",\"channelUid\":\"ch_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_mimo\",\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\tdeps := &AutoManagedDeps{CfgManager: manager, MiMoConsoleClient: &MiMoConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL}}\n\trouter := setupAutoManagedRouter(deps)\n\tbody := `{\"cookie\":\"api-platform_serviceToken=session; userId=42\"}`\n\trequest := func(payload string) *httptest.ResponseRecorder {\n\t\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_mimo/credentials/cred_mimo/mimo-console-cookie\", bytes.NewBufferString(payload))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tw := httptest.NewRecorder()\n\t\trouter.ServeHTTP(w, req)\n\t\treturn w\n\t}\n\tw := request(body)\n\tif w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), `\"code\":\"mimo_cookie_key_mismatch\"`) {\n\t\tt.Fatalf(\"未要求确认: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ := manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != oldKey || credential.MiMoConsole != nil {\n\t\tt.Fatalf(\"确认前不应修改配置: %+v\", credential)\n\t}\n\tw = request(`{\"cookie\":\"api-platform_serviceToken=session; userId=42\",\"adoptCookieKey\":true}`)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"keyAdopted\":true`) {\n\t\tt.Fatalf(\"确认采用失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ = manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != cookieKey || credential.MiMoConsole == nil {\n\t\tt.Fatalf(\"确认后未采用 Cookie Key: %+v\", credential)\n\t}\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif strings.Contains(list.Body.String(), \"api-platform_serviceToken\") || strings.Contains(list.Body.String(), cookieKey) {\n\t\tt.Fatalf(\"账号列表泄漏 Cookie 或原始 Key: %s\", list.Body.String())\n\t}\n}\n\nfunc TestCompshareConsoleCookieBindingDoesNotExposeSecrets(t *testing.T) {\n\tconst currentKey = \"sk-cp-test-current\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != testCompshareCookie {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\n \"RetCode\": 0,\n \"UserPlans\": [{\n \"Code\": \"cppkg-test\", \"PlanCode\": \"cp-plan-test\", \"PlanName\": \"Pro\", \"DisplayName\": \"Pro 增强版\",\n \"LimitPer5h\": 3000, \"LimitPerWeek\": 7500, \"LimitPerMonth\": 19000,\n \"UsagePer5h\": 0, \"UsagePerWeek\": 100, \"UsagePerMonth\": 6496,\n \"ConcurrencyLimit\": 10, \"Status\": 1, \"ExpireAt\": 1785037981,\n \"Keys\": [{\"APIKey\": \"` + currentKey + `\"}]\n }]\n}`))\n\t}))\n\tdefer console.Close()\n\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_compshare\",\"providerId\":\"compshare\",\"name\":\"compshare\",\"credentials\":[{\"credentialUid\":\"cred_compshare\",\"apiKey\":\"` + currentKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_compshare\",\"channelUid\":\"ch_compshare\",\"providerId\":\"compshare\",\"name\":\"compshare\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://api.compshare.cn/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_compshare\",\"baseUrl\":\"https://api.compshare.cn/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: manager,\n\t\tCompshareConsoleClient: &CompshareConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL},\n\t})\n\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_compshare/credentials/cred_compshare/compshare-console-cookie\", bytes.NewBufferString(`{\"cookie\":\"`+testCompshareCookie+`\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"displayName\":\"Pro 增强版\"`) {\n\t\tt.Fatalf(\"绑定失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_compshare\", \"cred_compshare\")\n\tif !ok || credential.CompshareConsole == nil || credential.CompshareConsole.Cookie != testCompshareCookie {\n\t\tt.Fatalf(\"控制台套餐未持久化: %+v\", credential)\n\t}\n\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tresponse := list.Body.String()\n\tfor _, secret := range []string{\"U_JWT_TOKEN\", \"test-session\", \"test_user@console.compshare.cn\", \"org-test\", currentKey, \"cppkg-test\"} {\n\t\tif strings.Contains(response, secret) {\n\t\t\tt.Fatalf(\"账号列表泄漏敏感字段 %q: %s\", secret, response)\n\t\t}\n\t}\n\tfor _, expected := range []string{`\"hasCompshareConsoleCookie\":true`, `\"planCode\":\"cp-plan-test\"`, `\"monthlyUsage\":{\"used\":6496,\"limit\":19000`} {\n\t\tif !strings.Contains(response, expected) {\n\t\t\tt.Fatalf(\"账号列表缺少套餐字段 %s: %s\", expected, response)\n\t\t}\n\t}\n}\n\nfunc TestProviderAutoAddReusesExistingAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(`{\"providerId\":\"mimo\",\"apiKeys\":[\"sk-existing\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"追加已有 provider key status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 4 {\n\t\tt.Fatalf(\"旧账号应自动补齐模板中的协议渠道: %+v\", channels)\n\t}\n\tkinds := make(map[string]bool, len(channels))\n\tfor _, channel := range channels {\n\t\tkinds[channel.Kind] = true\n\t}\n\tfor _, kind := range []string{\"messages\", \"chat\", \"responses\", \"gemini\"} {\n\t\tif !kinds[kind] {\n\t\t\tt.Fatalf(\"旧账号缺少 %s route: %+v\", kind, channels)\n\t\t}\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.AccountUID != \"acct_test\" {\n\t\tt.Fatalf(\"响应未返回已有账号: body=%s err=%v\", w.Body.String(), err)\n\t}\n\tif len(response.Channels) != 4 {\n\t\tt.Fatalf(\"响应未返回补齐后的全部渠道: %+v\", response.Channels)\n\t}\n}\n\nfunc TestMissingProviderAccountRoutes(t *testing.T) {\n\ttmpl, ok := config.GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"缺少 deepseek provider 模板\")\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{ServiceType: \"claude\"},\n\t}}\n\tmissing := missingProviderAccountRoutes(tmpl, existing)\n\tif len(missing) != 2 || missing[0].ChannelKind != \"chat\" || missing[1].ChannelKind != \"responses\" {\n\t\tt.Fatalf(\"missing routes = %+v\", missing)\n\t}\n\tconfigs, baseURLs, err := bindProviderRouteKeys(tmpl, missing[0], []string{\"sk-existing\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(configs) != 1 || configs[0].BaseURL != \"https://api.deepseek.com\" || len(baseURLs) != 1 {\n\t\tt.Fatalf(\"route 绑定结果不正确: configs=%+v baseURLs=%v\", configs, baseURLs)\n\t}\n}\n\nfunc TestBindProviderRouteKeysPreservesExistingEndpointAffinity(t *testing.T) {\n\ttests := []struct {\n\t\tproviderID string\n\t\tapiKey string\n\t\texistingBaseURL string\n\t\ttargetKind string\n\t\twantBaseURL string\n\t}{\n\t\t{\n\t\t\tproviderID: \"mimo\", apiKey: \"tp-existing\",\n\t\t\texistingBaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\t\ttargetKind: \"chat\", wantBaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\",\n\t\t},\n\t\t{\n\t\t\tproviderID: \"volcengine\", apiKey: \"ark-existing\",\n\t\t\texistingBaseURL: \"https://ark.cn-beijing.volces.com/api/coding\",\n\t\t\ttargetKind: \"responses\", wantBaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.providerID, func(t *testing.T) {\n\t\t\ttmpl, ok := config.GetProviderTemplate(tt.providerID)\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"缺少 provider 模板: %s\", tt.providerID)\n\t\t\t}\n\t\t\texisting := []config.AccountChannel{{\n\t\t\t\tKind: \"messages\",\n\t\t\t\tUpstream: config.UpstreamConfig{\n\t\t\t\t\tProviderID: tt.providerID, ServiceType: \"claude\", APIKeys: []string{tt.apiKey},\n\t\t\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: tt.apiKey, BaseURL: tt.existingBaseURL}},\n\t\t\t\t},\n\t\t\t}}\n\t\t\tvar target config.ProviderRoute\n\t\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\t\tif route.ChannelKind == tt.targetKind {\n\t\t\t\t\ttarget = route\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tconfigs, _, err := bindProviderRouteKeysWithAffinities(\n\t\t\t\ttmpl, target, []string{tt.apiKey}, providerKeyCandidateAffinities(tmpl, existing),\n\t\t\t)\n\t\t\tif err != nil || len(configs) != 1 || configs[0].BaseURL != tt.wantBaseURL {\n\t\t\t\tt.Fatalf(\"亲和绑定错误: configs=%+v err=%v want=%s\", configs, err, tt.wantBaseURL)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPlanProviderAccountRouteAdditionsRejectsNewKeyBeforeMutation(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t}))\n\tdefer server.Close()\n\n\ttmpl := &config.ProviderTemplate{\n\t\tProviderID: \"test-provider\",\n\t\tRoutes: []config.ProviderRoute{\n\t\t\t{ChannelKind: \"messages\", ServiceType: \"claude\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/anthropic\"}}},\n\t\t\t{ChannelKind: \"chat\", ServiceType: \"openai\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/v1\"}}},\n\t\t},\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct_test\", ChannelUID: \"ch_messages\", Name: \"test-provider\",\n\t\t\tProviderID: \"test-provider\", ServiceType: \"claude\", AutoManaged: true, APIKeys: []string{\"sk-existing\"},\n\t\t},\n\t}}\n\tadditions, status, err := planProviderAccountRouteAdditions(\n\t\tt.Context(), config.Config{}, tmpl, \"acct_test\", []string{\"sk-existing\", \"sk-invalid\"}, existing,\n\t)\n\tif err == nil || status != http.StatusBadRequest || len(additions) != 0 {\n\t\tt.Fatalf(\"无效新 Key 应在生成新增 route 前失败: status=%d additions=%+v err=%v\", status, additions, err)\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}, "7b1c218af00bbc9c5e5b95362cb2f7aaf002d650": {"t": 1785759338.3357244, "n": 55701, "h": "4fe28fc7da390d41", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/BenedictKing/ccx/internal/errutil\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestListAccountsMasksCredentials(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-secret-value\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil)\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"sk-secret-value\") {\n\t\tt.Fatalf(\"账号列表泄露明文 Key: %s\", body)\n\t}\n\tif !strings.Contains(body, \"cred_test\") || !strings.Contains(body, \"keyMask\") {\n\t\tt.Fatalf(\"账号列表缺少凭证掩码信息: %s\", body)\n\t}\n}\n\nfunc TestListAccountsIncludesActiveEndpointModelAvailability(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"volcengine\",\"name\":\"volcengine\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"ark-secret-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"ark-secret-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"volcengine\",\"name\":\"volcengine-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://example.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tstore, err := NewProfileStoreWithDB(newTestDB(t))\n\tif err != nil {\n\t\tt.Fatalf(\"NewProfileStoreWithDB 失败: %v\", err)\n\t}\n\tupdatedAt := time.Date(2026, 7, 17, 10, 54, 33, 0, time.UTC)\n\tdiscoveredAt := updatedAt\n\tdiscoveredAtLater := updatedAt.Add(time.Minute)\n\tfor _, profile := range []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_a\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_a\", KeyMask: \"ark-a***001\", AvailableModels: []string{\"model-b\", \"model-a\"}, UpdatedAt: updatedAt,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI, ModelsDiscoveredAt: &discoveredAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_b\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_b\", KeyMask: \"ark-b***002\", AvailableModels: []string{\"model-c\", \"model-b\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI, ModelsDiscoveredAt: &discoveredAtLater,\n\t\t},\n\t} {\n\t\tif err := store.Upsert(profile); err != nil {\n\t\t\tt.Fatalf(\"ProfileStore.Upsert 失败: %v\", err)\n\t\t}\n\t}\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager, Runner: runner})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response struct {\n\t\tAccounts []managedAccountView `json:\"accounts\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatalf(\"解析响应失败: %v\", err)\n\t}\n\tif len(response.Accounts) != 1 || len(response.Accounts[0].Channels) != 1 {\n\t\tt.Fatalf(\"账号渠道数量错误: %+v\", response.Accounts)\n\t}\n\tchannel := response.Accounts[0].Channels[0]\n\tif got, want := strings.Join(channel.DiscoveredModels, \",\"), \"model-a,model-b,model-c\"; got != want {\n\t\tt.Fatalf(\"DiscoveredModels=%q, want %q\", got, want)\n\t}\n\tif !channel.ModelInventoryKnown {\n\t\tt.Fatal(\"已发现模型时 ModelInventoryKnown 应为 true\")\n\t}\n\tif len(channel.ModelBindings) != 2 {\n\t\tt.Fatalf(\"ModelBindings=%+v, want 2\", channel.ModelBindings)\n\t}\n\tif got := strings.Join(channel.ModelBindings[0].Models, \",\"); got != \"model-a,model-b\" {\n\t\tt.Fatalf(\"第一个 Key 模型=%q\", got)\n\t}\n\tif channel.ModelsUpdatedAt == nil || !channel.ModelsUpdatedAt.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"ModelsUpdatedAt=%v\", channel.ModelsUpdatedAt)\n\t}\n\tif strings.Contains(w.Body.String(), \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", w.Body.String())\n\t}\n}\n\nfunc TestManagedChannelModelAvailabilityIncludesEmptyAutoDiscoveryResult(t *testing.T) {\n\tupdatedAt := time.Date(2026, 7, 17, 11, 2, 0, 0, time.UTC)\n\tmodels, bindings, latest, known := managedChannelModelAvailability([]*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_empty\", CredentialUID: \"cred_empty\", KeyMask: \"ark-e***001\",\n\t\t\tSource: \"auto_discovery\", AvailableModels: []string{}, UpdatedAt: updatedAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_models\", CredentialUID: \"cred_models\", KeyMask: \"ark-m***002\",\n\t\t\tAvailableModels: []string{\"model-a\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_unknown\", CredentialUID: \"cred_unknown\", KeyMask: \"ark-u***003\",\n\t\t\tSource: \"l1_passive\", UpdatedAt: updatedAt.Add(2 * time.Minute),\n\t\t},\n\t})\n\n\tif !known {\n\t\tt.Fatal(\"自动发现的空模型清单仍应标记为已知\")\n\t}\n\tif got, want := strings.Join(models, \",\"), \"model-a\"; got != want {\n\t\tt.Fatalf(\"models=%q, want %q\", got, want)\n\t}\n\tif len(bindings) != 2 {\n\t\tt.Fatalf(\"bindings=%+v, want only two known inventories\", bindings)\n\t}\n\tif got := bindings[0]; got.KeyMask != \"ark-e***001\" || len(got.Models) != 0 {\n\t\tt.Fatalf(\"empty discovery binding=%+v, want empty ark-e binding\", got)\n\t}\n\tif latest == nil || !latest.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"latest=%v, want discovered inventory update time\", latest)\n\t}\n}\n\nfunc TestManagedChannelModelAvailabilityIncludesDiscoveryMetadata(t *testing.T) {\n\tfirst := time.Date(2026, 7, 22, 0, 42, 12, 0, time.UTC)\n\tsecond := first.Add(time.Minute)\n\tinventory := managedChannelModelAvailabilityDetails([]*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep-a\",\n\t\t\tCredentialUID: \"cred-a\",\n\t\t\tKeyMask: \"ark-a***001\",\n\t\t\tAvailableModels: []string{\"glm-5.2\"},\n\t\t\tUpdatedAt: first,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceControlPlane,\n\t\t\tModelDiscoveryMessage: \"火山管控面 Coding Plan 模型清单\",\n\t\t\tModelsDiscoveredAt: &first,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep-b\",\n\t\t\tCredentialUID: \"cred-b\",\n\t\t\tKeyMask: \"ark-b***002\",\n\t\t\tAvailableModels: []string{\"deepseek-v4-pro\"},\n\t\t\tUpdatedAt: second,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceBuiltinFallback,\n\t\t\tModelDiscoveryMessage: \"管控面失败,已回退内置模型清单\",\n\t\t\tModelsDiscoveredAt: &second,\n\t\t},\n\t})\n\n\tif !inventory.known || inventory.source != \"mixed\" {\n\t\tt.Fatalf(\"inventory source=%q known=%v\", inventory.source, inventory.known)\n\t}\n\tif !inventory.latestDiscoveredAt.Equal(second) || inventory.message != \"管控面失败,已回退内置模型清单\" {\n\t\tt.Fatalf(\"inventory discovery metadata=%+v\", inventory)\n\t}\n\tif len(inventory.bindings) != 2 || inventory.bindings[1].ModelsDiscoveredAt == nil {\n\t\tt.Fatalf(\"binding discovery metadata=%+v\", inventory.bindings)\n\t}\n}\n\nfunc TestManagedProtocolAvailabilityViewsAggregatesByProtocolAndCredential(t *testing.T) {\n\tdiscoveredAt := time.Date(2026, 7, 25, 10, 0, 0, 0, time.UTC)\n\tprofiles := []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep-a\", CredentialUID: \"cred-a\", KeyMask: \"sk-a***001\", ServiceType: \"responses\",\n\t\t\tUpdatedAt: discoveredAt, ProtocolModels: map[string][]string{\n\t\t\t\t\"responses\": {\"shared\", \"responses-only\"},\n\t\t\t\t\"chat\": {\"shared\"},\n\t\t\t},\n\t\t\tProtocolDiscoveredAt: map[string]time.Time{\"responses\": discoveredAt, \"chat\": discoveredAt},\n\t\t\tProtocolDiscoverySource: map[string]string{\"responses\": \"models_api\", \"chat\": \"protocol_probe\"},\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep-b\", CredentialUID: \"cred-b\", KeyMask: \"sk-b***002\", ServiceType: \"responses\",\n\t\t\tUpdatedAt: discoveredAt, ProtocolModels: map[string][]string{\n\t\t\t\t\"responses\": {\"shared\"},\n\t\t\t\t\"messages\": {\"shared\", \"messages-only\"},\n\t\t\t},\n\t\t\tProtocolDiscoveredAt: map[string]time.Time{\"responses\": discoveredAt, \"messages\": discoveredAt},\n\t\t\tProtocolDiscoverySource: map[string]string{\"responses\": \"models_api\", \"messages\": \"protocol_probe\"},\n\t\t},\n\t}\n\n\tviews := managedProtocolAvailabilityViews(profiles)\n\tif len(views) != 3 {\n\t\tt.Fatalf(\"views=%+v\", views)\n\t}\n\tif views[0].Protocol != \"messages\" || strings.Join(views[0].DiscoveredModels, \",\") != \"messages-only,shared\" {\n\t\tt.Fatalf(\"messages view=%+v\", views[0])\n\t}\n\tif views[2].Protocol != \"responses\" || len(views[2].ModelBindings) != 2 {\n\t\tt.Fatalf(\"responses view=%+v\", views[2])\n\t}\n}\n\nfunc TestPatchAccountCredentialsRemovesByUID(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"sk-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"sk-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"baseUrls\":[\"https://api.xiaomimimo.com/anthropic\"],\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPatch, \"/api/accounts/acct_test/credentials\", bytes.NewBufferString(`{\"removeCredentialUids\":[\"cred_b\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PATCH credentials status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 1 || len(channels[0].Upstream.APIKeys) != 1 || channels[0].Upstream.APIKeys[0] != \"sk-a\" {\n\t\tt.Fatalf(\"按 credentialUid 删除失败: %+v\", channels)\n\t}\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestInferAutoAddProviderID(t *testing.T) {\n\tzhipuKey := \"0123456789abcdef0123456789abcdef.ABCDEFGHIJKLMNO1\"\n\ttests := []struct {\n\t\tname string\n\t\tbaseURLs []string\n\t\tapiKeys []string\n\t\twant string\n\t}{\n\t\t{name: \"官方 Claude URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"官方 OpenAI URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4/\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"两个官方协议 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\", \"https://open.bigmodel.cn/api/paas/v4\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"仅智谱 Key\", apiKeys: []string{zhipuKey}, want: \"glm\"},\n\t\t{name: \"第三方 URL 不按 Key 提升\", baseURLs: []string{\"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"混合官方与第三方 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4\", \"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"共享 sk Key 无法推断\", apiKeys: []string{\"sk-abcdefghijklmnopqrstuvwxyz123456\"}},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := inferAutoAddProviderID(tt.baseURLs, tt.apiKeys); got != tt.want {\n\t\t\t\tt.Fatalf(\"inferAutoAddProviderID() = %q, want %q\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestApplyProviderUpstreamDefaults(t *testing.T) {\n\tglmChat := config.UpstreamConfig{ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmChat)\n\tif glmChat.ReasoningParamStyle != \"reasoning_effort\" || !glmChat.PassbackReasoningContent {\n\t\tt.Fatalf(\"GLM OpenAI 默认兼容参数未补齐: %+v\", glmChat)\n\t}\n\n\tglmClaude := config.UpstreamConfig{ServiceType: \"claude\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmClaude)\n\tif glmClaude.ReasoningParamStyle != \"\" || glmClaude.PassbackReasoningContent {\n\t\tt.Fatalf(\"GLM Claude 原生 route 不应注入 OpenAI 参数: %+v\", glmClaude)\n\t}\n\n\tcustom := config.UpstreamConfig{ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"\", &custom)\n\tif custom.ReasoningParamStyle != \"\" || custom.PassbackReasoningContent {\n\t\tt.Fatalf(\"自定义渠道不应应用 GLM 默认值: %+v\", custom)\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCustomAutoAddResponseIncludesActualRoute(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\tdiscoverer := NewRateLimitDiscoverer(RateLimitDiscovererConfig{PassiveAimdEnabled: true, QuietLogs: true})\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, RateLimitDiscoverer: discoverer})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"fastaitoken-com-test\",\"baseUrls\":[\"https://example.com\"],\"apiKeys\":[\"sk-test\"],\"rateLimitHint\":{\"initialRpm\":30,\"effectiveRpm\":15,\"rateLimited\":true,\"rateLimitedCount\":1}}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 {\n\t\tt.Fatalf(\"channels=%+v, want one actual route\", response.Channels)\n\t}\n\troute := response.Channels[0]\n\tif route.ChannelKind != \"responses\" || route.ServiceType != \"responses\" || route.ChannelUID != response.ChannelUID {\n\t\tt.Fatalf(\"route=%+v response=%+v\", route, response)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.ResponsesUpstream) != 1 || len(cfg.Upstream) != 0 {\n\t\tt.Fatalf(\"custom route persisted in wrong channel: responses=%d messages=%d\", len(cfg.ResponsesUpstream), len(cfg.Upstream))\n\t}\n\tcreated := cfg.ResponsesUpstream[0]\n\tbaseURL := created.GetEffectiveBaseURL()\n\tendpointUID := GenerateEndpointUID(created.ChannelUID, baseURL, KeyHashFromAPIKey(\"sk-test\"))\n\tif suggestion := discoverer.SuggestedLimit(endpointUID); suggestion.RPM != 15 || suggestion.Confidence != 0.7 {\n\t\tt.Fatalf(\"rate limit suggestion=%+v, want rpm=15 confidence=0.7\", suggestion)\n\t}\n\n\tstore := newTestProfileStore(t)\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\tseedAutoAddRateLimitHint(\n\t\t&AutoManagedDeps{CfgManager: manager, Runner: runner, RateLimitDiscoverer: discoverer},\n\t\tmanager.GetAccountChannels(response.AccountUID),\n\t\t&AutoAddRateLimitHint{InitialRPM: 30, EffectiveRPM: 15, RateLimited: true, RateLimitedCount: 1},\n\t\t\"sk-test\",\n\t\t\"https://example.com\",\n\t)\n\tprofile := store.Get(endpointUID)\n\tif profile == nil || profile.DiscoveredRPM != 15 || profile.RateLimitConfidence != 0.7 || profile.RateLimitSource != string(RateLimitSourcePassiveAIMD) {\n\t\tt.Fatalf(\"rate limit profile=%+v\", profile)\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyToEquivalentExistingBaseURL(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [],\n \"chatUpstream\": [{\"channelUid\":\"ch_existing\",\"name\":\"localhost-old-name\",\"serviceType\":\"openai\",\"baseUrl\":\"http://localhost:8990/v1\",\"apiKeys\":[\"sk-old\"],\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"localhost-8990\",\"baseUrls\":[\"http://localhost:8990/\"],\"apiKeys\":[\"sk-new\"]}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 || response.Channels[0].Name != \"localhost-old-name\" {\n\t\tt.Fatalf(\"应返回已有渠道: %+v\", response.Channels)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 0 || len(cfg.ChatUpstream) != 1 {\n\t\tt.Fatalf(\"重复请求不应创建新渠道: messages=%d chat=%d\", len(cfg.Upstream), len(cfg.ChatUpstream))\n\t}\n\tif got := strings.Join(cfg.ChatUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"已有渠道密钥=%q, want sk-old,sk-new\", got)\n\t}\n}\n\nfunc TestFindExistingAutoAddChannelsPreservesHashSemantics(t *testing.T) {\n\tcfg := config.Config{\n\t\tChatUpstream: []config.UpstreamConfig{{\n\t\t\tChannelUID: \"ch-hash\", ServiceType: \"openai\", BaseURL: \"https://example.com#\",\n\t\t}},\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com\"}); len(matches) != 0 {\n\t\tt.Fatalf(\"普通 Base URL 不应匹配禁止版本前缀的地址: %+v\", matches)\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com/#\"}); len(matches) != 1 {\n\t\tt.Fatalf(\"等效 # 地址匹配数量=%d, want 1\", len(matches))\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyAndMissingRouteToManagedAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [{\"accountUid\":\"acct_existing\",\"channelUid\":\"ch_existing\",\"name\":\"localhost-8990\",\"serviceType\":\"claude\",\"baseUrl\":\"http://localhost:8990\",\"apiKeys\":[\"sk-old\"],\"autoManaged\":true,\"status\":\"active\"}],\n \"chatUpstream\": [], \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"localhost-8990\",\n \"baseUrls\":[\"http://localhost:8990/v1\"],\n \"apiKeys\":[\"sk-new\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"claude-test\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"gpt-test\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d\", len(cfg.Upstream), len(cfg.ResponsesUpstream))\n\t}\n\tif cfg.Upstream[0].AccountUID != \"acct_existing\" || cfg.ResponsesUpstream[0].AccountUID != \"acct_existing\" {\n\t\tt.Fatalf(\"应复用已有账号: messages=%q responses=%q\", cfg.Upstream[0].AccountUID, cfg.ResponsesUpstream[0].AccountUID)\n\t}\n\tif got := strings.Join(cfg.Upstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"messages 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif got := strings.Join(cfg.ResponsesUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"responses 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif cfg.Upstream[0].Name != \"localhost-8990-claude\" || cfg.ResponsesUpstream[0].Name != \"localhost-8990-codex\" {\n\t\tt.Fatalf(\"多协议自动命名错误: messages=%q responses=%q\", cfg.Upstream[0].Name, cfg.ResponsesUpstream[0].Name)\n\t}\n}\n\nfunc TestCustomAutoAddCreatesAllDetectedRoutesWithProtocolModels(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"fastaitoken-com-test\",\n \"baseUrls\":[\"https://example.com/keys\"],\n \"apiKeys\":[\"sk-test\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"shared\",\"messages-only\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"shared\",\"responses-only\"]},\n {\"channelKind\":\"chat\",\"supportedModels\":[\"shared\",\"chat-only\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 3 {\n\t\tt.Fatalf(\"channels=%+v, want three detected routes\", response.Channels)\n\t}\n\tfor _, route := range response.Channels {\n\t\tif route.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route account=%q, want %q\", route.AccountUID, response.AccountUID)\n\t\t}\n\t}\n\tif response.Channels[1].ChannelKind != \"responses\" || response.ChannelUID != response.Channels[1].ChannelUID {\n\t\tt.Fatalf(\"请求协议应作为主路由: response=%+v\", response)\n\t}\n\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 || len(cfg.ChatUpstream) != 1 || len(cfg.GeminiUpstream) != 0 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d chat=%d gemini=%d\",\n\t\t\tlen(cfg.Upstream), len(cfg.ResponsesUpstream), len(cfg.ChatUpstream), len(cfg.GeminiUpstream))\n\t}\n\tchecks := []struct {\n\t\tchannel config.UpstreamConfig\n\t\tname string\n\t\tmodels []string\n\t}{\n\t\t{cfg.Upstream[0], \"fastaitoken-com-test-claude\", []string{\"shared\", \"messages-only\"}},\n\t\t{cfg.ResponsesUpstream[0], \"fastaitoken-com-test-codex\", []string{\"shared\", \"responses-only\"}},\n\t\t{cfg.ChatUpstream[0], \"fastaitoken-com-test-chat\", []string{\"shared\", \"chat-only\"}},\n\t}\n\tfor _, check := range checks {\n\t\tif check.channel.Name != check.name || check.channel.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route identity=%+v, want name=%q account=%q\", check.channel, check.name, response.AccountUID)\n\t\t}\n\t\tif strings.Join(check.channel.SupportedModels, \",\") != strings.Join(check.models, \",\") {\n\t\t\tt.Fatalf(\"route %s models=%v, want %v\", check.name, check.channel.SupportedModels, check.models)\n\t\t}\n\t}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesRenamesAndSyncsCredentials(t *testing.T) {\n\tchannels := []config.AccountChannel{\n\t\t{Kind: \"messages\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-messages\", Name: \"old-claude\",\n\t\t\tServiceType: \"claude\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t\t{Kind: \"responses\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-responses\", Name: \"old-codex\",\n\t\t\tServiceType: \"responses\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t}\n\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\tName: \"renamed\", APIKeys: []string{\"sk-old\", \"sk-new\"},\n\t}, channels, len(channels))\n\tif err != nil || status != http.StatusOK {\n\t\tt.Fatalf(\"status=%d err=%v\", status, err)\n\t}\n\tif len(updates) != 2 || updates[0].Name != \"renamed-claude\" || updates[1].Name != \"renamed-codex\" {\n\t\tt.Fatalf(\"route names=%+v\", updates)\n\t}\n\tfor _, update := range updates {\n\t\tif len(update.APIKeyConfig) != 2 || update.APIKeyConfig[1].BaseURL != \"https://example.com\" {\n\t\t\tt.Fatalf(\"route credentials=%+v\", update.APIKeyConfig)\n\t\t}\n\t\tif update.APIKeyConfig[1].CredentialUID == \"\" {\n\t\t\tt.Fatalf(\"new credential missing stable uid: %+v\", update.APIKeyConfig[1])\n\t\t}\n\t}\n}\n\nfunc TestProviderRouteNameAndPrimaryResult(t *testing.T) {\n\tbase := \"mimo-test\"\n\ttests := []struct {\n\t\troute config.ProviderRoute\n\t\twant string\n\t}{\n\t\t{route: config.ProviderRoute{ChannelKind: \"messages\"}, want: \"mimo-test-claude\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"chat\"}, want: \"mimo-test-chat\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"responses\"}, want: \"mimo-test-codex\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"gemini\"}, want: \"mimo-test-gemini\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := providerRouteName(base, tt.route, true); got != tt.want {\n\t\t\tt.Fatalf(\"providerRouteName(%s)=%q, want %q\", tt.route.ChannelKind, got, tt.want)\n\t\t}\n\t}\n\tif got := providerRouteName(base, config.ProviderRoute{ChannelKind: \"messages\"}, false); got != base {\n\t\tt.Fatalf(\"single route name=%q, want %q\", got, base)\n\t}\n\n\tresults := []AutoAddChannelResult{\n\t\t{ChannelKind: \"messages\", ChannelUID: \"ch_messages\", Index: 1},\n\t\t{ChannelKind: \"chat\", ChannelUID: \"ch_chat\", Index: 2},\n\t\t{ChannelKind: \"responses\", ChannelUID: \"ch_responses\", Index: 3},\n\t}\n\tprimary := primaryAutoAddResult(results, \"chat\")\n\tif primary.ChannelUID != \"ch_chat\" || primary.Index != 2 {\n\t\tt.Fatalf(\"primary=%+v, want chat result\", primary)\n\t}\n}\n\nfunc TestManagedAccountUIDForProviderUsesExistingAccount(t *testing.T) {\n\tcfg := config.Config{ManagedAccounts: []config.ManagedAccountConfig{\n\t\t{AccountUID: \"acct-old\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-new\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-deepseek\", ProviderID: \"deepseek\"},\n\t}}\n\tif got := managedAccountUIDForProvider(cfg, \"mimo\"); got != \"acct-new\" {\n\t\tt.Fatalf(\"mimo existing account = %q, want acct-new\", got)\n\t}\n\tif got := managedAccountUIDForProvider(cfg, \"unknown\"); got != \"\" {\n\t\tt.Fatalf(\"unknown provider 不应命中账号: %q\", got)\n\t}\n}\n\nfunc TestListManagedAccountsDoesNotExposeVolcengineSecret(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-secret\",\"volcengineAccessKey\":{\"accessKeyId\":\"AKIDEXAMPLE\",\"secretAccessKey\":\"SECRET_MUST_NOT_LEAK\",\"plan\":\"agent_plan\",\"planTier\":\"Large\",\"planStatus\":\"Running\"}}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"SECRET_MUST_NOT_LEAK\") || strings.Contains(body, \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", body)\n\t}\n\tfor _, expected := range []string{`\"hasVolcengineAccessKey\":true`, `\"volcenginePlan\":\"agent_plan\"`, `\"volcenginePlanTier\":\"Large\"`} {\n\t\tif !strings.Contains(body, expected) {\n\t\t\tt.Fatalf(\"响应缺少 %s: %s\", expected, body)\n\t\t}\n\t}\n}\n\nfunc TestKimiConsoleTokenLifecycleDoesNotExposeToken(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_kimi\",\"providerId\":\"kimi\",\"name\":\"kimi\",\"credentials\":[{\"credentialUid\":\"cred_kimi\",\"apiKey\":\"sk-kimi-secret\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_kimi\",\"channelUid\":\"ch_kimi\",\"providerId\":\"kimi\",\"name\":\"kimi\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.kimi.com/coding\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_kimi\",\"baseUrl\":\"https://api.kimi.com/coding\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tkimiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Authorization\") != \"Bearer web-session-secret\" {\n\t\t\tt.Fatalf(\"Kimi 请求未使用保存的会话令牌\")\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase kimiUsagesPath:\n\t\t\t_, _ = w.Write([]byte(`{\"usages\":[{\"scope\":\"FEATURE_CODING\",\"detail\":{\"limit\":\"100\",\"remaining\":\"80\",\"resetTime\":\"2026-07-27T16:13:43Z\"},\"limits\":[{\"window\":{\"duration\":300,\"timeUnit\":\"TIME_UNIT_MINUTE\"},\"detail\":{\"limit\":\"100\",\"remaining\":\"90\",\"resetTime\":\"2026-07-21T02:13:43Z\"}}]}],\"totalQuota\":{\"limit\":\"100\",\"remaining\":\"80\"}}`))\n\t\tcase kimiSubscriptionStatsPath:\n\t\t\t_, _ = w.Write([]byte(`{\"ratelimitCode5h\":{\"ratio\":0.1,\"enabled\":true},\"ratelimitCode7d\":{\"ratio\":0.2,\"enabled\":true},\"subscriptionBalance\":{\"feature\":\"FEATURE_OMNI\",\"type\":\"SUBSCRIPTION\",\"unit\":\"UNIT_CREDIT\",\"amountUsedRatio\":0.2,\"kimiCodeUsedRatio\":0.2,\"expireTime\":\"2026-08-20T16:14:07Z\"}}`))\n\t\tdefault:\n\t\t\thttp.NotFound(w, r)\n\t\t}\n\t}))\n\tdefer kimiServer.Close()\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: cfgManager,\n\t\tKimiConsoleClient: &KimiConsoleClient{\n\t\t\tHTTPClient: kimiServer.Client(), BaseURL: kimiServer.URL,\n\t\t},\n\t})\n\tput := httptest.NewRequest(\n\t\thttp.MethodPut,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token\",\n\t\tbytes.NewBufferString(`{\"accessToken\":\"Bearer web-session-secret\"}`),\n\t)\n\tput.Header.Set(\"Content-Type\", \"application/json\")\n\tputRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(putRecorder, put)\n\tif putRecorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"绑定 Kimi 令牌失败: status=%d body=%s\", putRecorder.Code, putRecorder.Body.String())\n\t}\n\tif strings.Contains(putRecorder.Body.String(), \"web-session-secret\") || strings.Contains(putRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"绑定响应泄露 Kimi 令牌: %s\", putRecorder.Body.String())\n\t}\n\tcredential, ok := cfgManager.GetManagedAccountCredential(\"acct_kimi\", \"cred_kimi\")\n\tif !ok || credential.KimiConsole == nil || credential.KimiConsole.AccessToken != \"web-session-secret\" {\n\t\tt.Fatalf(\"Kimi 令牌未持久化: %+v\", credential)\n\t}\n\tpersisted, err := os.ReadFile(configPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !strings.Contains(string(persisted), `\"accessToken\": \"web-session-secret\"`) {\n\t\tt.Fatalf(\"配置文件未保存 Kimi 令牌\")\n\t}\n\n\tlistRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(listRecorder, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif listRecorder.Code != http.StatusOK || strings.Contains(listRecorder.Body.String(), \"web-session-secret\") || strings.Contains(listRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"账号列表响应异常或泄露令牌: status=%d body=%s\", listRecorder.Code, listRecorder.Body.String())\n\t}\n\tif !strings.Contains(listRecorder.Body.String(), `\"hasKimiConsoleToken\":true`) || !strings.Contains(listRecorder.Body.String(), `\"kimiCodeUsage\"`) {\n\t\tt.Fatalf(\"账号列表缺少 Kimi 用量快照: %s\", listRecorder.Body.String())\n\t}\n\n\trefreshRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(refreshRecorder, httptest.NewRequest(\n\t\thttp.MethodPost,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token/refresh\",\n\t\tnil,\n\t))\n\tif refreshRecorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"刷新 Kimi 余量失败: status=%d body=%s\", refreshRecorder.Code, refreshRecorder.Body.String())\n\t}\n\tif strings.Contains(refreshRecorder.Body.String(), \"web-session-secret\") || strings.Contains(refreshRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"刷新响应泄露 Kimi 令牌: %s\", refreshRecorder.Body.String())\n\t}\n\n\tdeleteRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(deleteRecorder, httptest.NewRequest(\n\t\thttp.MethodDelete,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token\",\n\t\tnil,\n\t))\n\tif deleteRecorder.Code != http.StatusNoContent {\n\t\tt.Fatalf(\"解绑 Kimi 令牌失败: status=%d body=%s\", deleteRecorder.Code, deleteRecorder.Body.String())\n\t}\n\tcredential, _ = cfgManager.GetManagedAccountCredential(\"acct_kimi\", \"cred_kimi\")\n\tif credential.KimiConsole != nil {\n\t\tt.Fatalf(\"Kimi 令牌解绑后仍存在: %+v\", credential.KimiConsole)\n\t}\n\tpersisted, err = os.ReadFile(configPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(persisted), \"web-session-secret\") {\n\t\tt.Fatalf(\"解绑后配置文件仍包含 Kimi 令牌\")\n\t}\n}\n\nfunc TestSetVolcengineAccessKeyDetectsPlanBeforePersisting(t *testing.T) {\n\tcontrolPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Query().Get(\"Action\") != \"GetPersonalPlan\" {\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tvar body struct{ Plan string }\n\t\t_ = json.NewDecoder(r.Body).Decode(&body)\n\t\tif body.Plan == \"CodingPlan\" {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t_, _ = w.Write([]byte(`{\"ResponseMetadata\":{\"Error\":{\"Code\":\"ResourceNotFound.Plan\",\"Message\":\"not found\"}}}`))\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\"Result\":{\"PlanType\":\"Large\",\"Status\":\"Running\"}}`))\n\t}))\n\tdefer controlPlane.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-inference\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trunner := NewAutoDiscoveryRunner(nil, nil)\n\trunner.client = controlPlane.Client()\n\trunner.volcengineControlPlaneEndpoint = controlPlane.URL\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, Runner: runner})\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_volc/credentials/cred_volc/volcengine-access-key\", bytes.NewBufferString(`{\"accessKeyId\":\"AKID\",\"secretAccessKey\":\"SECRET\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"plan\":\"agent_plan\"`) {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_volc\", \"cred_volc\")\n\tif !ok || credential.VolcengineAccessKey == nil || credential.VolcengineAccessKey.SecretAccessKey != \"SECRET\" || credential.VolcengineAccessKey.Plan != \"agent_plan\" {\n\t\tt.Fatalf(\"凭证未正确持久化: %+v\", credential)\n\t}\n}\n\nfunc TestSetMiMoConsoleCookieRequiresConfirmationBeforeAdoptingKey(t *testing.T) {\n\tconst oldKey = \"tp-old-1234567890123456789012345678901234567890\"\n\tconst cookieKey = \"tp-new-1234567890123456789012345678901234567890\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != \"api-platform_serviceToken=session; userId=42\" {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase \"/api/v1/userProfile\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"userId\":\"42\"}}`))\n\t\tcase \"/api/v1/tokenPlan/detail\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"planCode\":\"max\",\"planName\":\"Max\",\"currentPeriodEnd\":\"2026-07-29 23:59:59\"}}`))\n\t\tcase \"/api/v1/tokenPlan/usage\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"monthUsage\":{\"percent\":0.25,\"items\":[{\"used\":25,\"limit\":100}]},\"usage\":{\"percent\":0.5,\"items\":[{\"used\":50,\"limit\":100}]}}}`))\n\t\tcase \"/api/v1/tokenPlan/apiKey/raw\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":\"` + cookieKey + `\"}`))\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}\n\t}))\n\tdefer console.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"credentials\":[{\"credentialUid\":\"cred_mimo\",\"apiKey\":\"` + oldKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_mimo\",\"channelUid\":\"ch_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_mimo\",\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\tdeps := &AutoManagedDeps{CfgManager: manager, MiMoConsoleClient: &MiMoConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL}}\n\trouter := setupAutoManagedRouter(deps)\n\tbody := `{\"cookie\":\"api-platform_serviceToken=session; userId=42\"}`\n\trequest := func(payload string) *httptest.ResponseRecorder {\n\t\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_mimo/credentials/cred_mimo/mimo-console-cookie\", bytes.NewBufferString(payload))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tw := httptest.NewRecorder()\n\t\trouter.ServeHTTP(w, req)\n\t\treturn w\n\t}\n\tw := request(body)\n\tif w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), `\"code\":\"mimo_cookie_key_mismatch\"`) {\n\t\tt.Fatalf(\"未要求确认: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ := manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != oldKey || credential.MiMoConsole != nil {\n\t\tt.Fatalf(\"确认前不应修改配置: %+v\", credential)\n\t}\n\tw = request(`{\"cookie\":\"api-platform_serviceToken=session; userId=42\",\"adoptCookieKey\":true}`)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"keyAdopted\":true`) {\n\t\tt.Fatalf(\"确认采用失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ = manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != cookieKey || credential.MiMoConsole == nil {\n\t\tt.Fatalf(\"确认后未采用 Cookie Key: %+v\", credential)\n\t}\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif strings.Contains(list.Body.String(), \"api-platform_serviceToken\") || strings.Contains(list.Body.String(), cookieKey) {\n\t\tt.Fatalf(\"账号列表泄漏 Cookie 或原始 Key: %s\", list.Body.String())\n\t}\n}\n\nfunc TestCompshareConsoleCookieBindingDoesNotExposeSecrets(t *testing.T) {\n\tconst currentKey = \"sk-cp-test-current\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != testCompshareCookie {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\n \"RetCode\": 0,\n \"UserPlans\": [{\n \"Code\": \"cppkg-test\", \"PlanCode\": \"cp-plan-test\", \"PlanName\": \"Pro\", \"DisplayName\": \"Pro 增强版\",\n \"LimitPer5h\": 3000, \"LimitPerWeek\": 7500, \"LimitPerMonth\": 19000,\n \"UsagePer5h\": 0, \"UsagePerWeek\": 100, \"UsagePerMonth\": 6496,\n \"ConcurrencyLimit\": 10, \"Status\": 1, \"ExpireAt\": 1785037981,\n \"Keys\": [{\"APIKey\": \"` + currentKey + `\"}]\n }]\n}`))\n\t}))\n\tdefer console.Close()\n\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_compshare\",\"providerId\":\"compshare\",\"name\":\"compshare\",\"credentials\":[{\"credentialUid\":\"cred_compshare\",\"apiKey\":\"` + currentKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_compshare\",\"channelUid\":\"ch_compshare\",\"providerId\":\"compshare\",\"name\":\"compshare\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://api.compshare.cn/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_compshare\",\"baseUrl\":\"https://api.compshare.cn/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: manager,\n\t\tCompshareConsoleClient: &CompshareConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL},\n\t})\n\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_compshare/credentials/cred_compshare/compshare-console-cookie\", bytes.NewBufferString(`{\"cookie\":\"`+testCompshareCookie+`\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"displayName\":\"Pro 增强版\"`) {\n\t\tt.Fatalf(\"绑定失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_compshare\", \"cred_compshare\")\n\tif !ok || credential.CompshareConsole == nil || credential.CompshareConsole.Cookie != testCompshareCookie {\n\t\tt.Fatalf(\"控制台套餐未持久化: %+v\", credential)\n\t}\n\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tresponse := list.Body.String()\n\tfor _, secret := range []string{\"U_JWT_TOKEN\", \"test-session\", \"test_user@console.compshare.cn\", \"org-test\", currentKey, \"cppkg-test\"} {\n\t\tif strings.Contains(response, secret) {\n\t\t\tt.Fatalf(\"账号列表泄漏敏感字段 %q: %s\", secret, response)\n\t\t}\n\t}\n\tfor _, expected := range []string{`\"hasCompshareConsoleCookie\":true`, `\"planCode\":\"cp-plan-test\"`, `\"monthlyUsage\":{\"used\":6496,\"limit\":19000`} {\n\t\tif !strings.Contains(response, expected) {\n\t\t\tt.Fatalf(\"账号列表缺少套餐字段 %s: %s\", expected, response)\n\t\t}\n\t}\n}\n\nfunc TestProviderAutoAddReusesExistingAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(`{\"providerId\":\"mimo\",\"apiKeys\":[\"sk-existing\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"追加已有 provider key status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 4 {\n\t\tt.Fatalf(\"旧账号应自动补齐模板中的协议渠道: %+v\", channels)\n\t}\n\tkinds := make(map[string]bool, len(channels))\n\tfor _, channel := range channels {\n\t\tkinds[channel.Kind] = true\n\t}\n\tfor _, kind := range []string{\"messages\", \"chat\", \"responses\", \"gemini\"} {\n\t\tif !kinds[kind] {\n\t\t\tt.Fatalf(\"旧账号缺少 %s route: %+v\", kind, channels)\n\t\t}\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.AccountUID != \"acct_test\" {\n\t\tt.Fatalf(\"响应未返回已有账号: body=%s err=%v\", w.Body.String(), err)\n\t}\n\tif len(response.Channels) != 4 {\n\t\tt.Fatalf(\"响应未返回补齐后的全部渠道: %+v\", response.Channels)\n\t}\n}\n\nfunc TestMissingProviderAccountRoutes(t *testing.T) {\n\ttmpl, ok := config.GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"缺少 deepseek provider 模板\")\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{ServiceType: \"claude\"},\n\t}}\n\tmissing := missingProviderAccountRoutes(tmpl, existing)\n\tif len(missing) != 2 || missing[0].ChannelKind != \"chat\" || missing[1].ChannelKind != \"responses\" {\n\t\tt.Fatalf(\"missing routes = %+v\", missing)\n\t}\n\tconfigs, baseURLs, err := bindProviderRouteKeys(tmpl, missing[0], []string{\"sk-existing\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(configs) != 1 || configs[0].BaseURL != \"https://api.deepseek.com\" || len(baseURLs) != 1 {\n\t\tt.Fatalf(\"route 绑定结果不正确: configs=%+v baseURLs=%v\", configs, baseURLs)\n\t}\n}\n\nfunc TestBindProviderRouteKeysPreservesExistingEndpointAffinity(t *testing.T) {\n\ttests := []struct {\n\t\tproviderID string\n\t\tapiKey string\n\t\texistingBaseURL string\n\t\ttargetKind string\n\t\twantBaseURL string\n\t}{\n\t\t{\n\t\t\tproviderID: \"mimo\", apiKey: \"tp-existing\",\n\t\t\texistingBaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\t\ttargetKind: \"chat\", wantBaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\",\n\t\t},\n\t\t{\n\t\t\tproviderID: \"volcengine\", apiKey: \"ark-existing\",\n\t\t\texistingBaseURL: \"https://ark.cn-beijing.volces.com/api/coding\",\n\t\t\ttargetKind: \"responses\", wantBaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.providerID, func(t *testing.T) {\n\t\t\ttmpl, ok := config.GetProviderTemplate(tt.providerID)\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"缺少 provider 模板: %s\", tt.providerID)\n\t\t\t}\n\t\t\texisting := []config.AccountChannel{{\n\t\t\t\tKind: \"messages\",\n\t\t\t\tUpstream: config.UpstreamConfig{\n\t\t\t\t\tProviderID: tt.providerID, ServiceType: \"claude\", APIKeys: []string{tt.apiKey},\n\t\t\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: tt.apiKey, BaseURL: tt.existingBaseURL}},\n\t\t\t\t},\n\t\t\t}}\n\t\t\tvar target config.ProviderRoute\n\t\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\t\tif route.ChannelKind == tt.targetKind {\n\t\t\t\t\ttarget = route\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tconfigs, _, err := bindProviderRouteKeysWithAffinities(\n\t\t\t\ttmpl, target, []string{tt.apiKey}, providerKeyCandidateAffinities(tmpl, existing),\n\t\t\t)\n\t\t\tif err != nil || len(configs) != 1 || configs[0].BaseURL != tt.wantBaseURL {\n\t\t\t\tt.Fatalf(\"亲和绑定错误: configs=%+v err=%v want=%s\", configs, err, tt.wantBaseURL)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPlanProviderAccountRouteAdditionsRejectsNewKeyBeforeMutation(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t}))\n\tdefer server.Close()\n\n\ttmpl := &config.ProviderTemplate{\n\t\tProviderID: \"test-provider\",\n\t\tRoutes: []config.ProviderRoute{\n\t\t\t{ChannelKind: \"messages\", ServiceType: \"claude\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/anthropic\"}}},\n\t\t\t{ChannelKind: \"chat\", ServiceType: \"openai\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/v1\"}}},\n\t\t},\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct_test\", ChannelUID: \"ch_messages\", Name: \"test-provider\",\n\t\t\tProviderID: \"test-provider\", ServiceType: \"claude\", AutoManaged: true, APIKeys: []string{\"sk-existing\"},\n\t\t},\n\t}}\n\tadditions, status, err := planProviderAccountRouteAdditions(\n\t\tt.Context(), config.Config{}, tmpl, \"acct_test\", []string{\"sk-existing\", \"sk-invalid\"}, existing,\n\t)\n\tif err == nil || status != http.StatusBadRequest || len(additions) != 0 {\n\t\tt.Fatalf(\"无效新 Key 应在生成新增 route 前失败: status=%d additions=%+v err=%v\", status, additions, err)\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}, "6a6b2055828ba187fd85475333e90bf7166f183c": {"t": 1785759340.0638592, "n": 54178, "h": "abbdde98d9a659d7", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/BenedictKing/ccx/internal/errutil\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestListAccountsMasksCredentials(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-secret-value\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil)\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"sk-secret-value\") {\n\t\tt.Fatalf(\"账号列表泄露明文 Key: %s\", body)\n\t}\n\tif !strings.Contains(body, \"cred_test\") || !strings.Contains(body, \"keyMask\") {\n\t\tt.Fatalf(\"账号列表缺少凭证掩码信息: %s\", body)\n\t}\n}\n\nfunc TestListAccountsIncludesActiveEndpointModelAvailability(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"volcengine\",\"name\":\"volcengine\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"ark-secret-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"ark-secret-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"volcengine\",\"name\":\"volcengine-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://example.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tstore, err := NewProfileStoreWithDB(newTestDB(t))\n\tif err != nil {\n\t\tt.Fatalf(\"NewProfileStoreWithDB 失败: %v\", err)\n\t}\n\tupdatedAt := time.Date(2026, 7, 17, 10, 54, 33, 0, time.UTC)\n\tdiscoveredAt := updatedAt\n\tdiscoveredAtLater := updatedAt.Add(time.Minute)\n\tfor _, profile := range []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_a\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_a\", KeyMask: \"ark-a***001\", AvailableModels: []string{\"model-b\", \"model-a\"}, UpdatedAt: updatedAt,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI, ModelsDiscoveredAt: &discoveredAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_b\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_b\", KeyMask: \"ark-b***002\", AvailableModels: []string{\"model-c\", \"model-b\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI, ModelsDiscoveredAt: &discoveredAtLater,\n\t\t},\n\t} {\n\t\tif err := store.Upsert(profile); err != nil {\n\t\t\tt.Fatalf(\"ProfileStore.Upsert 失败: %v\", err)\n\t\t}\n\t}\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager, Runner: runner})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response struct {\n\t\tAccounts []managedAccountView `json:\"accounts\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatalf(\"解析响应失败: %v\", err)\n\t}\n\tif len(response.Accounts) != 1 || len(response.Accounts[0].Channels) != 1 {\n\t\tt.Fatalf(\"账号渠道数量错误: %+v\", response.Accounts)\n\t}\n\tchannel := response.Accounts[0].Channels[0]\n\tif got, want := strings.Join(channel.DiscoveredModels, \",\"), \"model-a,model-b,model-c\"; got != want {\n\t\tt.Fatalf(\"DiscoveredModels=%q, want %q\", got, want)\n\t}\n\tif !channel.ModelInventoryKnown {\n\t\tt.Fatal(\"已发现模型时 ModelInventoryKnown 应为 true\")\n\t}\n\tif len(channel.ModelBindings) != 2 {\n\t\tt.Fatalf(\"ModelBindings=%+v, want 2\", channel.ModelBindings)\n\t}\n\tif got := strings.Join(channel.ModelBindings[0].Models, \",\"); got != \"model-a,model-b\" {\n\t\tt.Fatalf(\"第一个 Key 模型=%q\", got)\n\t}\n\tif channel.ModelsUpdatedAt == nil || !channel.ModelsUpdatedAt.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"ModelsUpdatedAt=%v\", channel.ModelsUpdatedAt)\n\t}\n\tif strings.Contains(w.Body.String(), \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", w.Body.String())\n\t}\n}\n\nfunc TestManagedChannelModelAvailabilityIncludesEmptyAutoDiscoveryResult(t *testing.T) {\n\tupdatedAt := time.Date(2026, 7, 17, 11, 2, 0, 0, time.UTC)\n\tmodels, bindings, latest, known := managedChannelModelAvailability([]*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_empty\", CredentialUID: \"cred_empty\", KeyMask: \"ark-e***001\",\n\t\t\tSource: \"auto_discovery\", AvailableModels: []string{}, UpdatedAt: updatedAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_models\", CredentialUID: \"cred_models\", KeyMask: \"ark-m***002\",\n\t\t\tAvailableModels: []string{\"model-a\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_unknown\", CredentialUID: \"cred_unknown\", KeyMask: \"ark-u***003\",\n\t\t\tSource: \"l1_passive\", UpdatedAt: updatedAt.Add(2 * time.Minute),\n\t\t},\n\t})\n\n\tif !known {\n\t\tt.Fatal(\"自动发现的空模型清单仍应标记为已知\")\n\t}\n\tif got, want := strings.Join(models, \",\"), \"model-a\"; got != want {\n\t\tt.Fatalf(\"models=%q, want %q\", got, want)\n\t}\n\tif len(bindings) != 2 {\n\t\tt.Fatalf(\"bindings=%+v, want only two known inventories\", bindings)\n\t}\n\tif got := bindings[0]; got.KeyMask != \"ark-e***001\" || len(got.Models) != 0 {\n\t\tt.Fatalf(\"empty discovery binding=%+v, want empty ark-e binding\", got)\n\t}\n\tif latest == nil || !latest.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"latest=%v, want discovered inventory update time\", latest)\n\t}\n}\n\nfunc TestManagedChannelModelAvailabilityIncludesDiscoveryMetadata(t *testing.T) {\n\tfirst := time.Date(2026, 7, 22, 0, 42, 12, 0, time.UTC)\n\tsecond := first.Add(time.Minute)\n\tinventory := managedChannelModelAvailabilityDetails([]*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep-a\",\n\t\t\tCredentialUID: \"cred-a\",\n\t\t\tKeyMask: \"ark-a***001\",\n\t\t\tAvailableModels: []string{\"glm-5.2\"},\n\t\t\tUpdatedAt: first,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceControlPlane,\n\t\t\tModelDiscoveryMessage: \"火山管控面 Coding Plan 模型清单\",\n\t\t\tModelsDiscoveredAt: &first,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep-b\",\n\t\t\tCredentialUID: \"cred-b\",\n\t\t\tKeyMask: \"ark-b***002\",\n\t\t\tAvailableModels: []string{\"deepseek-v4-pro\"},\n\t\t\tUpdatedAt: second,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceBuiltinFallback,\n\t\t\tModelDiscoveryMessage: \"管控面失败,已回退内置模型清单\",\n\t\t\tModelsDiscoveredAt: &second,\n\t\t},\n\t})\n\n\tif !inventory.known || inventory.source != \"mixed\" {\n\t\tt.Fatalf(\"inventory source=%q known=%v\", inventory.source, inventory.known)\n\t}\n\tif !inventory.latestDiscoveredAt.Equal(second) || inventory.message != \"管控面失败,已回退内置模型清单\" {\n\t\tt.Fatalf(\"inventory discovery metadata=%+v\", inventory)\n\t}\n\tif len(inventory.bindings) != 2 || inventory.bindings[1].ModelsDiscoveredAt == nil {\n\t\tt.Fatalf(\"binding discovery metadata=%+v\", inventory.bindings)\n\t}\n}\n\nfunc TestPatchAccountCredentialsRemovesByUID(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"sk-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"sk-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"baseUrls\":[\"https://api.xiaomimimo.com/anthropic\"],\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPatch, \"/api/accounts/acct_test/credentials\", bytes.NewBufferString(`{\"removeCredentialUids\":[\"cred_b\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PATCH credentials status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 1 || len(channels[0].Upstream.APIKeys) != 1 || channels[0].Upstream.APIKeys[0] != \"sk-a\" {\n\t\tt.Fatalf(\"按 credentialUid 删除失败: %+v\", channels)\n\t}\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestInferAutoAddProviderID(t *testing.T) {\n\tzhipuKey := \"0123456789abcdef0123456789abcdef.ABCDEFGHIJKLMNO1\"\n\ttests := []struct {\n\t\tname string\n\t\tbaseURLs []string\n\t\tapiKeys []string\n\t\twant string\n\t}{\n\t\t{name: \"官方 Claude URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"官方 OpenAI URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4/\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"两个官方协议 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\", \"https://open.bigmodel.cn/api/paas/v4\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"仅智谱 Key\", apiKeys: []string{zhipuKey}, want: \"glm\"},\n\t\t{name: \"第三方 URL 不按 Key 提升\", baseURLs: []string{\"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"混合官方与第三方 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4\", \"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"共享 sk Key 无法推断\", apiKeys: []string{\"sk-abcdefghijklmnopqrstuvwxyz123456\"}},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := inferAutoAddProviderID(tt.baseURLs, tt.apiKeys); got != tt.want {\n\t\t\t\tt.Fatalf(\"inferAutoAddProviderID() = %q, want %q\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestApplyProviderUpstreamDefaults(t *testing.T) {\n\tglmChat := config.UpstreamConfig{ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmChat)\n\tif glmChat.ReasoningParamStyle != \"reasoning_effort\" || !glmChat.PassbackReasoningContent {\n\t\tt.Fatalf(\"GLM OpenAI 默认兼容参数未补齐: %+v\", glmChat)\n\t}\n\n\tglmClaude := config.UpstreamConfig{ServiceType: \"claude\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmClaude)\n\tif glmClaude.ReasoningParamStyle != \"\" || glmClaude.PassbackReasoningContent {\n\t\tt.Fatalf(\"GLM Claude 原生 route 不应注入 OpenAI 参数: %+v\", glmClaude)\n\t}\n\n\tcustom := config.UpstreamConfig{ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"\", &custom)\n\tif custom.ReasoningParamStyle != \"\" || custom.PassbackReasoningContent {\n\t\tt.Fatalf(\"自定义渠道不应应用 GLM 默认值: %+v\", custom)\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCustomAutoAddResponseIncludesActualRoute(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\tdiscoverer := NewRateLimitDiscoverer(RateLimitDiscovererConfig{QuietLogs: true})\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, RateLimitDiscoverer: discoverer})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"fastaitoken-com-test\",\"baseUrls\":[\"https://example.com\"],\"apiKeys\":[\"sk-test\"],\"rateLimitHint\":{\"initialRpm\":30,\"effectiveRpm\":15,\"rateLimited\":true,\"rateLimitedCount\":1}}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 {\n\t\tt.Fatalf(\"channels=%+v, want one actual route\", response.Channels)\n\t}\n\troute := response.Channels[0]\n\tif route.ChannelKind != \"responses\" || route.ServiceType != \"responses\" || route.ChannelUID != response.ChannelUID {\n\t\tt.Fatalf(\"route=%+v response=%+v\", route, response)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.ResponsesUpstream) != 1 || len(cfg.Upstream) != 0 {\n\t\tt.Fatalf(\"custom route persisted in wrong channel: responses=%d messages=%d\", len(cfg.ResponsesUpstream), len(cfg.Upstream))\n\t}\n\tcreated := cfg.ResponsesUpstream[0]\n\tbaseURL := created.GetEffectiveBaseURL()\n\tendpointUID := GenerateEndpointUID(created.ChannelUID, baseURL, KeyHashFromAPIKey(\"sk-test\"))\n\tif suggestion := discoverer.SuggestedLimit(endpointUID); suggestion.RPM != 15 || suggestion.Confidence != 0.7 {\n\t\tt.Fatalf(\"rate limit suggestion=%+v, want rpm=15 confidence=0.7\", suggestion)\n\t}\n\n\tstore := newTestProfileStore(t)\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\tseedAutoAddRateLimitHint(\n\t\t&AutoManagedDeps{CfgManager: manager, Runner: runner, RateLimitDiscoverer: discoverer},\n\t\tmanager.GetAccountChannels(response.AccountUID),\n\t\t&AutoAddRateLimitHint{InitialRPM: 30, EffectiveRPM: 15, RateLimited: true, RateLimitedCount: 1},\n\t\t\"sk-test\",\n\t\t\"https://example.com\",\n\t)\n\tprofile := store.Get(endpointUID)\n\tif profile == nil || profile.DiscoveredRPM != 15 || profile.RateLimitConfidence != 0.7 || profile.RateLimitSource != string(RateLimitSourcePassiveAIMD) {\n\t\tt.Fatalf(\"rate limit profile=%+v\", profile)\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyToEquivalentExistingBaseURL(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [],\n \"chatUpstream\": [{\"channelUid\":\"ch_existing\",\"name\":\"localhost-old-name\",\"serviceType\":\"openai\",\"baseUrl\":\"http://localhost:8990/v1\",\"apiKeys\":[\"sk-old\"],\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"localhost-8990\",\"baseUrls\":[\"http://localhost:8990/\"],\"apiKeys\":[\"sk-new\"]}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 || response.Channels[0].Name != \"localhost-old-name\" {\n\t\tt.Fatalf(\"应返回已有渠道: %+v\", response.Channels)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 0 || len(cfg.ChatUpstream) != 1 {\n\t\tt.Fatalf(\"重复请求不应创建新渠道: messages=%d chat=%d\", len(cfg.Upstream), len(cfg.ChatUpstream))\n\t}\n\tif got := strings.Join(cfg.ChatUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"已有渠道密钥=%q, want sk-old,sk-new\", got)\n\t}\n}\n\nfunc TestFindExistingAutoAddChannelsPreservesHashSemantics(t *testing.T) {\n\tcfg := config.Config{\n\t\tChatUpstream: []config.UpstreamConfig{{\n\t\t\tChannelUID: \"ch-hash\", ServiceType: \"openai\", BaseURL: \"https://example.com#\",\n\t\t}},\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com\"}); len(matches) != 0 {\n\t\tt.Fatalf(\"普通 Base URL 不应匹配禁止版本前缀的地址: %+v\", matches)\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com/#\"}); len(matches) != 1 {\n\t\tt.Fatalf(\"等效 # 地址匹配数量=%d, want 1\", len(matches))\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyAndMissingRouteToManagedAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [{\"accountUid\":\"acct_existing\",\"channelUid\":\"ch_existing\",\"name\":\"localhost-8990\",\"serviceType\":\"claude\",\"baseUrl\":\"http://localhost:8990\",\"apiKeys\":[\"sk-old\"],\"autoManaged\":true,\"status\":\"active\"}],\n \"chatUpstream\": [], \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"localhost-8990\",\n \"baseUrls\":[\"http://localhost:8990/v1\"],\n \"apiKeys\":[\"sk-new\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"claude-test\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"gpt-test\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d\", len(cfg.Upstream), len(cfg.ResponsesUpstream))\n\t}\n\tif cfg.Upstream[0].AccountUID != \"acct_existing\" || cfg.ResponsesUpstream[0].AccountUID != \"acct_existing\" {\n\t\tt.Fatalf(\"应复用已有账号: messages=%q responses=%q\", cfg.Upstream[0].AccountUID, cfg.ResponsesUpstream[0].AccountUID)\n\t}\n\tif got := strings.Join(cfg.Upstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"messages 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif got := strings.Join(cfg.ResponsesUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"responses 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif cfg.Upstream[0].Name != \"localhost-8990-claude\" || cfg.ResponsesUpstream[0].Name != \"localhost-8990-codex\" {\n\t\tt.Fatalf(\"多协议自动命名错误: messages=%q responses=%q\", cfg.Upstream[0].Name, cfg.ResponsesUpstream[0].Name)\n\t}\n}\n\nfunc TestCustomAutoAddCreatesAllDetectedRoutesWithProtocolModels(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"fastaitoken-com-test\",\n \"baseUrls\":[\"https://example.com/keys\"],\n \"apiKeys\":[\"sk-test\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"shared\",\"messages-only\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"shared\",\"responses-only\"]},\n {\"channelKind\":\"chat\",\"supportedModels\":[\"shared\",\"chat-only\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 3 {\n\t\tt.Fatalf(\"channels=%+v, want three detected routes\", response.Channels)\n\t}\n\tfor _, route := range response.Channels {\n\t\tif route.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route account=%q, want %q\", route.AccountUID, response.AccountUID)\n\t\t}\n\t}\n\tif response.Channels[1].ChannelKind != \"responses\" || response.ChannelUID != response.Channels[1].ChannelUID {\n\t\tt.Fatalf(\"请求协议应作为主路由: response=%+v\", response)\n\t}\n\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 || len(cfg.ChatUpstream) != 1 || len(cfg.GeminiUpstream) != 0 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d chat=%d gemini=%d\",\n\t\t\tlen(cfg.Upstream), len(cfg.ResponsesUpstream), len(cfg.ChatUpstream), len(cfg.GeminiUpstream))\n\t}\n\tchecks := []struct {\n\t\tchannel config.UpstreamConfig\n\t\tname string\n\t\tmodels []string\n\t}{\n\t\t{cfg.Upstream[0], \"fastaitoken-com-test-claude\", []string{\"shared\", \"messages-only\"}},\n\t\t{cfg.ResponsesUpstream[0], \"fastaitoken-com-test-codex\", []string{\"shared\", \"responses-only\"}},\n\t\t{cfg.ChatUpstream[0], \"fastaitoken-com-test-chat\", []string{\"shared\", \"chat-only\"}},\n\t}\n\tfor _, check := range checks {\n\t\tif check.channel.Name != check.name || check.channel.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route identity=%+v, want name=%q account=%q\", check.channel, check.name, response.AccountUID)\n\t\t}\n\t\tif strings.Join(check.channel.SupportedModels, \",\") != strings.Join(check.models, \",\") {\n\t\t\tt.Fatalf(\"route %s models=%v, want %v\", check.name, check.channel.SupportedModels, check.models)\n\t\t}\n\t}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesRenamesAndSyncsCredentials(t *testing.T) {\n\tchannels := []config.AccountChannel{\n\t\t{Kind: \"messages\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-messages\", Name: \"old-claude\",\n\t\t\tServiceType: \"claude\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t\t{Kind: \"responses\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-responses\", Name: \"old-codex\",\n\t\t\tServiceType: \"responses\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t}\n\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\tName: \"renamed\", APIKeys: []string{\"sk-old\", \"sk-new\"},\n\t}, channels, len(channels))\n\tif err != nil || status != http.StatusOK {\n\t\tt.Fatalf(\"status=%d err=%v\", status, err)\n\t}\n\tif len(updates) != 2 || updates[0].Name != \"renamed-claude\" || updates[1].Name != \"renamed-codex\" {\n\t\tt.Fatalf(\"route names=%+v\", updates)\n\t}\n\tfor _, update := range updates {\n\t\tif len(update.APIKeyConfig) != 2 || update.APIKeyConfig[1].BaseURL != \"https://example.com\" {\n\t\t\tt.Fatalf(\"route credentials=%+v\", update.APIKeyConfig)\n\t\t}\n\t\tif update.APIKeyConfig[1].CredentialUID == \"\" {\n\t\t\tt.Fatalf(\"new credential missing stable uid: %+v\", update.APIKeyConfig[1])\n\t\t}\n\t}\n}\n\nfunc TestProviderRouteNameAndPrimaryResult(t *testing.T) {\n\tbase := \"mimo-test\"\n\ttests := []struct {\n\t\troute config.ProviderRoute\n\t\twant string\n\t}{\n\t\t{route: config.ProviderRoute{ChannelKind: \"messages\"}, want: \"mimo-test-claude\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"chat\"}, want: \"mimo-test-chat\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"responses\"}, want: \"mimo-test-codex\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"gemini\"}, want: \"mimo-test-gemini\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := providerRouteName(base, tt.route, true); got != tt.want {\n\t\t\tt.Fatalf(\"providerRouteName(%s)=%q, want %q\", tt.route.ChannelKind, got, tt.want)\n\t\t}\n\t}\n\tif got := providerRouteName(base, config.ProviderRoute{ChannelKind: \"messages\"}, false); got != base {\n\t\tt.Fatalf(\"single route name=%q, want %q\", got, base)\n\t}\n\n\tresults := []AutoAddChannelResult{\n\t\t{ChannelKind: \"messages\", ChannelUID: \"ch_messages\", Index: 1},\n\t\t{ChannelKind: \"chat\", ChannelUID: \"ch_chat\", Index: 2},\n\t\t{ChannelKind: \"responses\", ChannelUID: \"ch_responses\", Index: 3},\n\t}\n\tprimary := primaryAutoAddResult(results, \"chat\")\n\tif primary.ChannelUID != \"ch_chat\" || primary.Index != 2 {\n\t\tt.Fatalf(\"primary=%+v, want chat result\", primary)\n\t}\n}\n\nfunc TestManagedAccountUIDForProviderUsesExistingAccount(t *testing.T) {\n\tcfg := config.Config{ManagedAccounts: []config.ManagedAccountConfig{\n\t\t{AccountUID: \"acct-old\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-new\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-deepseek\", ProviderID: \"deepseek\"},\n\t}}\n\tif got := managedAccountUIDForProvider(cfg, \"mimo\"); got != \"acct-new\" {\n\t\tt.Fatalf(\"mimo existing account = %q, want acct-new\", got)\n\t}\n\tif got := managedAccountUIDForProvider(cfg, \"unknown\"); got != \"\" {\n\t\tt.Fatalf(\"unknown provider 不应命中账号: %q\", got)\n\t}\n}\n\nfunc TestListManagedAccountsDoesNotExposeVolcengineSecret(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-secret\",\"volcengineAccessKey\":{\"accessKeyId\":\"AKIDEXAMPLE\",\"secretAccessKey\":\"SECRET_MUST_NOT_LEAK\",\"plan\":\"agent_plan\",\"planTier\":\"Large\",\"planStatus\":\"Running\"}}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"SECRET_MUST_NOT_LEAK\") || strings.Contains(body, \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", body)\n\t}\n\tfor _, expected := range []string{`\"hasVolcengineAccessKey\":true`, `\"volcenginePlan\":\"agent_plan\"`, `\"volcenginePlanTier\":\"Large\"`} {\n\t\tif !strings.Contains(body, expected) {\n\t\t\tt.Fatalf(\"响应缺少 %s: %s\", expected, body)\n\t\t}\n\t}\n}\n\nfunc TestKimiConsoleTokenLifecycleDoesNotExposeToken(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_kimi\",\"providerId\":\"kimi\",\"name\":\"kimi\",\"credentials\":[{\"credentialUid\":\"cred_kimi\",\"apiKey\":\"sk-kimi-secret\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_kimi\",\"channelUid\":\"ch_kimi\",\"providerId\":\"kimi\",\"name\":\"kimi\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.kimi.com/coding\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_kimi\",\"baseUrl\":\"https://api.kimi.com/coding\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tkimiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Authorization\") != \"Bearer web-session-secret\" {\n\t\t\tt.Fatalf(\"Kimi 请求未使用保存的会话令牌\")\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase kimiUsagesPath:\n\t\t\t_, _ = w.Write([]byte(`{\"usages\":[{\"scope\":\"FEATURE_CODING\",\"detail\":{\"limit\":\"100\",\"remaining\":\"80\",\"resetTime\":\"2026-07-27T16:13:43Z\"},\"limits\":[{\"window\":{\"duration\":300,\"timeUnit\":\"TIME_UNIT_MINUTE\"},\"detail\":{\"limit\":\"100\",\"remaining\":\"90\",\"resetTime\":\"2026-07-21T02:13:43Z\"}}]}],\"totalQuota\":{\"limit\":\"100\",\"remaining\":\"80\"}}`))\n\t\tcase kimiSubscriptionStatsPath:\n\t\t\t_, _ = w.Write([]byte(`{\"ratelimitCode5h\":{\"ratio\":0.1,\"enabled\":true},\"ratelimitCode7d\":{\"ratio\":0.2,\"enabled\":true},\"subscriptionBalance\":{\"feature\":\"FEATURE_OMNI\",\"type\":\"SUBSCRIPTION\",\"unit\":\"UNIT_CREDIT\",\"amountUsedRatio\":0.2,\"kimiCodeUsedRatio\":0.2,\"expireTime\":\"2026-08-20T16:14:07Z\"}}`))\n\t\tdefault:\n\t\t\thttp.NotFound(w, r)\n\t\t}\n\t}))\n\tdefer kimiServer.Close()\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: cfgManager,\n\t\tKimiConsoleClient: &KimiConsoleClient{\n\t\t\tHTTPClient: kimiServer.Client(), BaseURL: kimiServer.URL,\n\t\t},\n\t})\n\tput := httptest.NewRequest(\n\t\thttp.MethodPut,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token\",\n\t\tbytes.NewBufferString(`{\"accessToken\":\"Bearer web-session-secret\"}`),\n\t)\n\tput.Header.Set(\"Content-Type\", \"application/json\")\n\tputRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(putRecorder, put)\n\tif putRecorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"绑定 Kimi 令牌失败: status=%d body=%s\", putRecorder.Code, putRecorder.Body.String())\n\t}\n\tif strings.Contains(putRecorder.Body.String(), \"web-session-secret\") || strings.Contains(putRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"绑定响应泄露 Kimi 令牌: %s\", putRecorder.Body.String())\n\t}\n\tcredential, ok := cfgManager.GetManagedAccountCredential(\"acct_kimi\", \"cred_kimi\")\n\tif !ok || credential.KimiConsole == nil || credential.KimiConsole.AccessToken != \"web-session-secret\" {\n\t\tt.Fatalf(\"Kimi 令牌未持久化: %+v\", credential)\n\t}\n\tpersisted, err := os.ReadFile(configPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !strings.Contains(string(persisted), `\"accessToken\": \"web-session-secret\"`) {\n\t\tt.Fatalf(\"配置文件未保存 Kimi 令牌\")\n\t}\n\n\tlistRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(listRecorder, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif listRecorder.Code != http.StatusOK || strings.Contains(listRecorder.Body.String(), \"web-session-secret\") || strings.Contains(listRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"账号列表响应异常或泄露令牌: status=%d body=%s\", listRecorder.Code, listRecorder.Body.String())\n\t}\n\tif !strings.Contains(listRecorder.Body.String(), `\"hasKimiConsoleToken\":true`) || !strings.Contains(listRecorder.Body.String(), `\"kimiCodeUsage\"`) {\n\t\tt.Fatalf(\"账号列表缺少 Kimi 用量快照: %s\", listRecorder.Body.String())\n\t}\n\n\trefreshRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(refreshRecorder, httptest.NewRequest(\n\t\thttp.MethodPost,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token/refresh\",\n\t\tnil,\n\t))\n\tif refreshRecorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"刷新 Kimi 余量失败: status=%d body=%s\", refreshRecorder.Code, refreshRecorder.Body.String())\n\t}\n\tif strings.Contains(refreshRecorder.Body.String(), \"web-session-secret\") || strings.Contains(refreshRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"刷新响应泄露 Kimi 令牌: %s\", refreshRecorder.Body.String())\n\t}\n\n\tdeleteRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(deleteRecorder, httptest.NewRequest(\n\t\thttp.MethodDelete,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token\",\n\t\tnil,\n\t))\n\tif deleteRecorder.Code != http.StatusNoContent {\n\t\tt.Fatalf(\"解绑 Kimi 令牌失败: status=%d body=%s\", deleteRecorder.Code, deleteRecorder.Body.String())\n\t}\n\tcredential, _ = cfgManager.GetManagedAccountCredential(\"acct_kimi\", \"cred_kimi\")\n\tif credential.KimiConsole != nil {\n\t\tt.Fatalf(\"Kimi 令牌解绑后仍存在: %+v\", credential.KimiConsole)\n\t}\n\tpersisted, err = os.ReadFile(configPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(persisted), \"web-session-secret\") {\n\t\tt.Fatalf(\"解绑后配置文件仍包含 Kimi 令牌\")\n\t}\n}\n\nfunc TestSetVolcengineAccessKeyDetectsPlanBeforePersisting(t *testing.T) {\n\tcontrolPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Query().Get(\"Action\") != \"GetPersonalPlan\" {\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tvar body struct{ Plan string }\n\t\t_ = json.NewDecoder(r.Body).Decode(&body)\n\t\tif body.Plan == \"CodingPlan\" {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t_, _ = w.Write([]byte(`{\"ResponseMetadata\":{\"Error\":{\"Code\":\"ResourceNotFound.Plan\",\"Message\":\"not found\"}}}`))\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\"Result\":{\"PlanType\":\"Large\",\"Status\":\"Running\"}}`))\n\t}))\n\tdefer controlPlane.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-inference\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trunner := NewAutoDiscoveryRunner(nil, nil)\n\trunner.client = controlPlane.Client()\n\trunner.volcengineControlPlaneEndpoint = controlPlane.URL\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, Runner: runner})\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_volc/credentials/cred_volc/volcengine-access-key\", bytes.NewBufferString(`{\"accessKeyId\":\"AKID\",\"secretAccessKey\":\"SECRET\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"plan\":\"agent_plan\"`) {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_volc\", \"cred_volc\")\n\tif !ok || credential.VolcengineAccessKey == nil || credential.VolcengineAccessKey.SecretAccessKey != \"SECRET\" || credential.VolcengineAccessKey.Plan != \"agent_plan\" {\n\t\tt.Fatalf(\"凭证未正确持久化: %+v\", credential)\n\t}\n}\n\nfunc TestSetMiMoConsoleCookieRequiresConfirmationBeforeAdoptingKey(t *testing.T) {\n\tconst oldKey = \"tp-old-1234567890123456789012345678901234567890\"\n\tconst cookieKey = \"tp-new-1234567890123456789012345678901234567890\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != \"api-platform_serviceToken=session; userId=42\" {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase \"/api/v1/userProfile\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"userId\":\"42\"}}`))\n\t\tcase \"/api/v1/tokenPlan/detail\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"planCode\":\"max\",\"planName\":\"Max\",\"currentPeriodEnd\":\"2026-07-29 23:59:59\"}}`))\n\t\tcase \"/api/v1/tokenPlan/usage\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"monthUsage\":{\"percent\":0.25,\"items\":[{\"used\":25,\"limit\":100}]},\"usage\":{\"percent\":0.5,\"items\":[{\"used\":50,\"limit\":100}]}}}`))\n\t\tcase \"/api/v1/tokenPlan/apiKey/raw\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":\"` + cookieKey + `\"}`))\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}\n\t}))\n\tdefer console.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"credentials\":[{\"credentialUid\":\"cred_mimo\",\"apiKey\":\"` + oldKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_mimo\",\"channelUid\":\"ch_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_mimo\",\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\tdeps := &AutoManagedDeps{CfgManager: manager, MiMoConsoleClient: &MiMoConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL}}\n\trouter := setupAutoManagedRouter(deps)\n\tbody := `{\"cookie\":\"api-platform_serviceToken=session; userId=42\"}`\n\trequest := func(payload string) *httptest.ResponseRecorder {\n\t\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_mimo/credentials/cred_mimo/mimo-console-cookie\", bytes.NewBufferString(payload))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tw := httptest.NewRecorder()\n\t\trouter.ServeHTTP(w, req)\n\t\treturn w\n\t}\n\tw := request(body)\n\tif w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), `\"code\":\"mimo_cookie_key_mismatch\"`) {\n\t\tt.Fatalf(\"未要求确认: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ := manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != oldKey || credential.MiMoConsole != nil {\n\t\tt.Fatalf(\"确认前不应修改配置: %+v\", credential)\n\t}\n\tw = request(`{\"cookie\":\"api-platform_serviceToken=session; userId=42\",\"adoptCookieKey\":true}`)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"keyAdopted\":true`) {\n\t\tt.Fatalf(\"确认采用失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ = manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != cookieKey || credential.MiMoConsole == nil {\n\t\tt.Fatalf(\"确认后未采用 Cookie Key: %+v\", credential)\n\t}\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif strings.Contains(list.Body.String(), \"api-platform_serviceToken\") || strings.Contains(list.Body.String(), cookieKey) {\n\t\tt.Fatalf(\"账号列表泄漏 Cookie 或原始 Key: %s\", list.Body.String())\n\t}\n}\n\nfunc TestCompshareConsoleCookieBindingDoesNotExposeSecrets(t *testing.T) {\n\tconst currentKey = \"sk-cp-test-current\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != testCompshareCookie {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\n \"RetCode\": 0,\n \"UserPlans\": [{\n \"Code\": \"cppkg-test\", \"PlanCode\": \"cp-plan-test\", \"PlanName\": \"Pro\", \"DisplayName\": \"Pro 增强版\",\n \"LimitPer5h\": 3000, \"LimitPerWeek\": 7500, \"LimitPerMonth\": 19000,\n \"UsagePer5h\": 0, \"UsagePerWeek\": 100, \"UsagePerMonth\": 6496,\n \"ConcurrencyLimit\": 10, \"Status\": 1, \"ExpireAt\": 1785037981,\n \"Keys\": [{\"APIKey\": \"` + currentKey + `\"}]\n }]\n}`))\n\t}))\n\tdefer console.Close()\n\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_compshare\",\"providerId\":\"compshare\",\"name\":\"compshare\",\"credentials\":[{\"credentialUid\":\"cred_compshare\",\"apiKey\":\"` + currentKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_compshare\",\"channelUid\":\"ch_compshare\",\"providerId\":\"compshare\",\"name\":\"compshare\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://api.compshare.cn/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_compshare\",\"baseUrl\":\"https://api.compshare.cn/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: manager,\n\t\tCompshareConsoleClient: &CompshareConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL},\n\t})\n\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_compshare/credentials/cred_compshare/compshare-console-cookie\", bytes.NewBufferString(`{\"cookie\":\"`+testCompshareCookie+`\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"displayName\":\"Pro 增强版\"`) {\n\t\tt.Fatalf(\"绑定失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_compshare\", \"cred_compshare\")\n\tif !ok || credential.CompshareConsole == nil || credential.CompshareConsole.Cookie != testCompshareCookie {\n\t\tt.Fatalf(\"控制台套餐未持久化: %+v\", credential)\n\t}\n\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tresponse := list.Body.String()\n\tfor _, secret := range []string{\"U_JWT_TOKEN\", \"test-session\", \"test_user@console.compshare.cn\", \"org-test\", currentKey, \"cppkg-test\"} {\n\t\tif strings.Contains(response, secret) {\n\t\t\tt.Fatalf(\"账号列表泄漏敏感字段 %q: %s\", secret, response)\n\t\t}\n\t}\n\tfor _, expected := range []string{`\"hasCompshareConsoleCookie\":true`, `\"planCode\":\"cp-plan-test\"`, `\"monthlyUsage\":{\"used\":6496,\"limit\":19000`} {\n\t\tif !strings.Contains(response, expected) {\n\t\t\tt.Fatalf(\"账号列表缺少套餐字段 %s: %s\", expected, response)\n\t\t}\n\t}\n}\n\nfunc TestProviderAutoAddReusesExistingAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(`{\"providerId\":\"mimo\",\"apiKeys\":[\"sk-existing\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"追加已有 provider key status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 4 {\n\t\tt.Fatalf(\"旧账号应自动补齐模板中的协议渠道: %+v\", channels)\n\t}\n\tkinds := make(map[string]bool, len(channels))\n\tfor _, channel := range channels {\n\t\tkinds[channel.Kind] = true\n\t}\n\tfor _, kind := range []string{\"messages\", \"chat\", \"responses\", \"gemini\"} {\n\t\tif !kinds[kind] {\n\t\t\tt.Fatalf(\"旧账号缺少 %s route: %+v\", kind, channels)\n\t\t}\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.AccountUID != \"acct_test\" {\n\t\tt.Fatalf(\"响应未返回已有账号: body=%s err=%v\", w.Body.String(), err)\n\t}\n\tif len(response.Channels) != 4 {\n\t\tt.Fatalf(\"响应未返回补齐后的全部渠道: %+v\", response.Channels)\n\t}\n}\n\nfunc TestMissingProviderAccountRoutes(t *testing.T) {\n\ttmpl, ok := config.GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"缺少 deepseek provider 模板\")\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{ServiceType: \"claude\"},\n\t}}\n\tmissing := missingProviderAccountRoutes(tmpl, existing)\n\tif len(missing) != 2 || missing[0].ChannelKind != \"chat\" || missing[1].ChannelKind != \"responses\" {\n\t\tt.Fatalf(\"missing routes = %+v\", missing)\n\t}\n\tconfigs, baseURLs, err := bindProviderRouteKeys(tmpl, missing[0], []string{\"sk-existing\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(configs) != 1 || configs[0].BaseURL != \"https://api.deepseek.com\" || len(baseURLs) != 1 {\n\t\tt.Fatalf(\"route 绑定结果不正确: configs=%+v baseURLs=%v\", configs, baseURLs)\n\t}\n}\n\nfunc TestBindProviderRouteKeysPreservesExistingEndpointAffinity(t *testing.T) {\n\ttests := []struct {\n\t\tproviderID string\n\t\tapiKey string\n\t\texistingBaseURL string\n\t\ttargetKind string\n\t\twantBaseURL string\n\t}{\n\t\t{\n\t\t\tproviderID: \"mimo\", apiKey: \"tp-existing\",\n\t\t\texistingBaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\t\ttargetKind: \"chat\", wantBaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\",\n\t\t},\n\t\t{\n\t\t\tproviderID: \"volcengine\", apiKey: \"ark-existing\",\n\t\t\texistingBaseURL: \"https://ark.cn-beijing.volces.com/api/coding\",\n\t\t\ttargetKind: \"responses\", wantBaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.providerID, func(t *testing.T) {\n\t\t\ttmpl, ok := config.GetProviderTemplate(tt.providerID)\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"缺少 provider 模板: %s\", tt.providerID)\n\t\t\t}\n\t\t\texisting := []config.AccountChannel{{\n\t\t\t\tKind: \"messages\",\n\t\t\t\tUpstream: config.UpstreamConfig{\n\t\t\t\t\tProviderID: tt.providerID, ServiceType: \"claude\", APIKeys: []string{tt.apiKey},\n\t\t\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: tt.apiKey, BaseURL: tt.existingBaseURL}},\n\t\t\t\t},\n\t\t\t}}\n\t\t\tvar target config.ProviderRoute\n\t\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\t\tif route.ChannelKind == tt.targetKind {\n\t\t\t\t\ttarget = route\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tconfigs, _, err := bindProviderRouteKeysWithAffinities(\n\t\t\t\ttmpl, target, []string{tt.apiKey}, providerKeyCandidateAffinities(tmpl, existing),\n\t\t\t)\n\t\t\tif err != nil || len(configs) != 1 || configs[0].BaseURL != tt.wantBaseURL {\n\t\t\t\tt.Fatalf(\"亲和绑定错误: configs=%+v err=%v want=%s\", configs, err, tt.wantBaseURL)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPlanProviderAccountRouteAdditionsRejectsNewKeyBeforeMutation(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t}))\n\tdefer server.Close()\n\n\ttmpl := &config.ProviderTemplate{\n\t\tProviderID: \"test-provider\",\n\t\tRoutes: []config.ProviderRoute{\n\t\t\t{ChannelKind: \"messages\", ServiceType: \"claude\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/anthropic\"}}},\n\t\t\t{ChannelKind: \"chat\", ServiceType: \"openai\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/v1\"}}},\n\t\t},\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct_test\", ChannelUID: \"ch_messages\", Name: \"test-provider\",\n\t\t\tProviderID: \"test-provider\", ServiceType: \"claude\", AutoManaged: true, APIKeys: []string{\"sk-existing\"},\n\t\t},\n\t}}\n\tadditions, status, err := planProviderAccountRouteAdditions(\n\t\tt.Context(), config.Config{}, tmpl, \"acct_test\", []string{\"sk-existing\", \"sk-invalid\"}, existing,\n\t)\n\tif err == nil || status != http.StatusBadRequest || len(additions) != 0 {\n\t\tt.Fatalf(\"无效新 Key 应在生成新增 route 前失败: status=%d additions=%+v err=%v\", status, additions, err)\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}, "8535b7e2b677bce35a3b3611c9121b31b451e830": {"t": 1785759341.8271368, "n": 54042, "h": "5731fccfb2d98e78", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestListAccountsMasksCredentials(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-secret-value\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil)\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"sk-secret-value\") {\n\t\tt.Fatalf(\"账号列表泄露明文 Key: %s\", body)\n\t}\n\tif !strings.Contains(body, \"cred_test\") || !strings.Contains(body, \"keyMask\") {\n\t\tt.Fatalf(\"账号列表缺少凭证掩码信息: %s\", body)\n\t}\n}\n\nfunc TestListAccountsIncludesActiveEndpointModelAvailability(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"volcengine\",\"name\":\"volcengine\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"ark-secret-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"ark-secret-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"volcengine\",\"name\":\"volcengine-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://example.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tstore, err := NewProfileStoreWithDB(newTestDB(t))\n\tif err != nil {\n\t\tt.Fatalf(\"NewProfileStoreWithDB 失败: %v\", err)\n\t}\n\tupdatedAt := time.Date(2026, 7, 17, 10, 54, 33, 0, time.UTC)\n\tdiscoveredAt := updatedAt\n\tdiscoveredAtLater := updatedAt.Add(time.Minute)\n\tfor _, profile := range []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_a\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_a\", KeyMask: \"ark-a***001\", AvailableModels: []string{\"model-b\", \"model-a\"}, UpdatedAt: updatedAt,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI, ModelsDiscoveredAt: &discoveredAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_b\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_b\", KeyMask: \"ark-b***002\", AvailableModels: []string{\"model-c\", \"model-b\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI, ModelsDiscoveredAt: &discoveredAtLater,\n\t\t},\n\t} {\n\t\tif err := store.Upsert(profile); err != nil {\n\t\t\tt.Fatalf(\"ProfileStore.Upsert 失败: %v\", err)\n\t\t}\n\t}\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager, Runner: runner})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response struct {\n\t\tAccounts []managedAccountView `json:\"accounts\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatalf(\"解析响应失败: %v\", err)\n\t}\n\tif len(response.Accounts) != 1 || len(response.Accounts[0].Channels) != 1 {\n\t\tt.Fatalf(\"账号渠道数量错误: %+v\", response.Accounts)\n\t}\n\tchannel := response.Accounts[0].Channels[0]\n\tif got, want := strings.Join(channel.DiscoveredModels, \",\"), \"model-a,model-b,model-c\"; got != want {\n\t\tt.Fatalf(\"DiscoveredModels=%q, want %q\", got, want)\n\t}\n\tif !channel.ModelInventoryKnown {\n\t\tt.Fatal(\"已发现模型时 ModelInventoryKnown 应为 true\")\n\t}\n\tif len(channel.ModelBindings) != 2 {\n\t\tt.Fatalf(\"ModelBindings=%+v, want 2\", channel.ModelBindings)\n\t}\n\tif got := strings.Join(channel.ModelBindings[0].Models, \",\"); got != \"model-a,model-b\" {\n\t\tt.Fatalf(\"第一个 Key 模型=%q\", got)\n\t}\n\tif channel.ModelsUpdatedAt == nil || !channel.ModelsUpdatedAt.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"ModelsUpdatedAt=%v\", channel.ModelsUpdatedAt)\n\t}\n\tif strings.Contains(w.Body.String(), \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", w.Body.String())\n\t}\n}\n\nfunc TestManagedChannelModelAvailabilityIncludesEmptyAutoDiscoveryResult(t *testing.T) {\n\tupdatedAt := time.Date(2026, 7, 17, 11, 2, 0, 0, time.UTC)\n\tmodels, bindings, latest, known := managedChannelModelAvailability([]*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_empty\", CredentialUID: \"cred_empty\", KeyMask: \"ark-e***001\",\n\t\t\tSource: \"auto_discovery\", AvailableModels: []string{}, UpdatedAt: updatedAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_models\", CredentialUID: \"cred_models\", KeyMask: \"ark-m***002\",\n\t\t\tAvailableModels: []string{\"model-a\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_unknown\", CredentialUID: \"cred_unknown\", KeyMask: \"ark-u***003\",\n\t\t\tSource: \"l1_passive\", UpdatedAt: updatedAt.Add(2 * time.Minute),\n\t\t},\n\t})\n\n\tif !known {\n\t\tt.Fatal(\"自动发现的空模型清单仍应标记为已知\")\n\t}\n\tif got, want := strings.Join(models, \",\"), \"model-a\"; got != want {\n\t\tt.Fatalf(\"models=%q, want %q\", got, want)\n\t}\n\tif len(bindings) != 2 {\n\t\tt.Fatalf(\"bindings=%+v, want only two known inventories\", bindings)\n\t}\n\tif got := bindings[0]; got.KeyMask != \"ark-e***001\" || len(got.Models) != 0 {\n\t\tt.Fatalf(\"empty discovery binding=%+v, want empty ark-e binding\", got)\n\t}\n\tif latest == nil || !latest.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"latest=%v, want discovered inventory update time\", latest)\n\t}\n}\n\nfunc TestManagedChannelModelAvailabilityIncludesDiscoveryMetadata(t *testing.T) {\n\tfirst := time.Date(2026, 7, 22, 0, 42, 12, 0, time.UTC)\n\tsecond := first.Add(time.Minute)\n\tinventory := managedChannelModelAvailabilityDetails([]*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep-a\",\n\t\t\tCredentialUID: \"cred-a\",\n\t\t\tKeyMask: \"ark-a***001\",\n\t\t\tAvailableModels: []string{\"glm-5.2\"},\n\t\t\tUpdatedAt: first,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceControlPlane,\n\t\t\tModelDiscoveryMessage: \"火山管控面 Coding Plan 模型清单\",\n\t\t\tModelsDiscoveredAt: &first,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep-b\",\n\t\t\tCredentialUID: \"cred-b\",\n\t\t\tKeyMask: \"ark-b***002\",\n\t\t\tAvailableModels: []string{\"deepseek-v4-pro\"},\n\t\t\tUpdatedAt: second,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceBuiltinFallback,\n\t\t\tModelDiscoveryMessage: \"管控面失败,已回退内置模型清单\",\n\t\t\tModelsDiscoveredAt: &second,\n\t\t},\n\t})\n\n\tif !inventory.known || inventory.source != \"mixed\" {\n\t\tt.Fatalf(\"inventory source=%q known=%v\", inventory.source, inventory.known)\n\t}\n\tif !inventory.latestDiscoveredAt.Equal(second) || inventory.message != \"管控面失败,已回退内置模型清单\" {\n\t\tt.Fatalf(\"inventory discovery metadata=%+v\", inventory)\n\t}\n\tif len(inventory.bindings) != 2 || inventory.bindings[1].ModelsDiscoveredAt == nil {\n\t\tt.Fatalf(\"binding discovery metadata=%+v\", inventory.bindings)\n\t}\n}\n\nfunc TestPatchAccountCredentialsRemovesByUID(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"sk-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"sk-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"baseUrls\":[\"https://api.xiaomimimo.com/anthropic\"],\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPatch, \"/api/accounts/acct_test/credentials\", bytes.NewBufferString(`{\"removeCredentialUids\":[\"cred_b\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PATCH credentials status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 1 || len(channels[0].Upstream.APIKeys) != 1 || channels[0].Upstream.APIKeys[0] != \"sk-a\" {\n\t\tt.Fatalf(\"按 credentialUid 删除失败: %+v\", channels)\n\t}\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestInferAutoAddProviderID(t *testing.T) {\n\tzhipuKey := \"0123456789abcdef0123456789abcdef.ABCDEFGHIJKLMNO1\"\n\ttests := []struct {\n\t\tname string\n\t\tbaseURLs []string\n\t\tapiKeys []string\n\t\twant string\n\t}{\n\t\t{name: \"官方 Claude URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"官方 OpenAI URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4/\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"两个官方协议 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\", \"https://open.bigmodel.cn/api/paas/v4\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"仅智谱 Key\", apiKeys: []string{zhipuKey}, want: \"glm\"},\n\t\t{name: \"第三方 URL 不按 Key 提升\", baseURLs: []string{\"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"混合官方与第三方 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4\", \"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"共享 sk Key 无法推断\", apiKeys: []string{\"sk-abcdefghijklmnopqrstuvwxyz123456\"}},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := inferAutoAddProviderID(tt.baseURLs, tt.apiKeys); got != tt.want {\n\t\t\t\tt.Fatalf(\"inferAutoAddProviderID() = %q, want %q\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestApplyProviderUpstreamDefaults(t *testing.T) {\n\tglmChat := config.UpstreamConfig{ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmChat)\n\tif glmChat.ReasoningParamStyle != \"reasoning_effort\" || !glmChat.PassbackReasoningContent {\n\t\tt.Fatalf(\"GLM OpenAI 默认兼容参数未补齐: %+v\", glmChat)\n\t}\n\n\tglmClaude := config.UpstreamConfig{ServiceType: \"claude\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmClaude)\n\tif glmClaude.ReasoningParamStyle != \"\" || glmClaude.PassbackReasoningContent {\n\t\tt.Fatalf(\"GLM Claude 原生 route 不应注入 OpenAI 参数: %+v\", glmClaude)\n\t}\n\n\tcustom := config.UpstreamConfig{ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"\", &custom)\n\tif custom.ReasoningParamStyle != \"\" || custom.PassbackReasoningContent {\n\t\tt.Fatalf(\"自定义渠道不应应用 GLM 默认值: %+v\", custom)\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCustomAutoAddResponseIncludesActualRoute(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\tdiscoverer := NewRateLimitDiscoverer(RateLimitDiscovererConfig{QuietLogs: true})\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, RateLimitDiscoverer: discoverer})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"fastaitoken-com-test\",\"baseUrls\":[\"https://example.com\"],\"apiKeys\":[\"sk-test\"],\"rateLimitHint\":{\"initialRpm\":30,\"effectiveRpm\":15,\"rateLimited\":true,\"rateLimitedCount\":1}}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 {\n\t\tt.Fatalf(\"channels=%+v, want one actual route\", response.Channels)\n\t}\n\troute := response.Channels[0]\n\tif route.ChannelKind != \"responses\" || route.ServiceType != \"responses\" || route.ChannelUID != response.ChannelUID {\n\t\tt.Fatalf(\"route=%+v response=%+v\", route, response)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.ResponsesUpstream) != 1 || len(cfg.Upstream) != 0 {\n\t\tt.Fatalf(\"custom route persisted in wrong channel: responses=%d messages=%d\", len(cfg.ResponsesUpstream), len(cfg.Upstream))\n\t}\n\tcreated := cfg.ResponsesUpstream[0]\n\tbaseURL := created.GetEffectiveBaseURL()\n\tendpointUID := GenerateEndpointUID(created.ChannelUID, baseURL, KeyHashFromAPIKey(\"sk-test\"))\n\tif suggestion := discoverer.SuggestedLimit(endpointUID); suggestion.RPM != 15 || suggestion.Confidence != 0.7 {\n\t\tt.Fatalf(\"rate limit suggestion=%+v, want rpm=15 confidence=0.7\", suggestion)\n\t}\n\n\tstore := newTestProfileStore(t)\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\tseedAutoAddRateLimitHint(\n\t\t&AutoManagedDeps{CfgManager: manager, Runner: runner, RateLimitDiscoverer: discoverer},\n\t\tmanager.GetAccountChannels(response.AccountUID),\n\t\t&AutoAddRateLimitHint{InitialRPM: 30, EffectiveRPM: 15, RateLimited: true, RateLimitedCount: 1},\n\t\t\"sk-test\",\n\t\t\"https://example.com\",\n\t)\n\tprofile := store.Get(endpointUID)\n\tif profile == nil || profile.DiscoveredRPM != 15 || profile.RateLimitConfidence != 0.7 || profile.RateLimitSource != string(RateLimitSourcePassiveAIMD) {\n\t\tt.Fatalf(\"rate limit profile=%+v\", profile)\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyToEquivalentExistingBaseURL(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [],\n \"chatUpstream\": [{\"channelUid\":\"ch_existing\",\"name\":\"localhost-old-name\",\"serviceType\":\"openai\",\"baseUrl\":\"http://localhost:8990/v1\",\"apiKeys\":[\"sk-old\"],\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"localhost-8990\",\"baseUrls\":[\"http://localhost:8990/\"],\"apiKeys\":[\"sk-new\"]}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 || response.Channels[0].Name != \"localhost-old-name\" {\n\t\tt.Fatalf(\"应返回已有渠道: %+v\", response.Channels)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 0 || len(cfg.ChatUpstream) != 1 {\n\t\tt.Fatalf(\"重复请求不应创建新渠道: messages=%d chat=%d\", len(cfg.Upstream), len(cfg.ChatUpstream))\n\t}\n\tif got := strings.Join(cfg.ChatUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"已有渠道密钥=%q, want sk-old,sk-new\", got)\n\t}\n}\n\nfunc TestFindExistingAutoAddChannelsPreservesHashSemantics(t *testing.T) {\n\tcfg := config.Config{\n\t\tChatUpstream: []config.UpstreamConfig{{\n\t\t\tChannelUID: \"ch-hash\", ServiceType: \"openai\", BaseURL: \"https://example.com#\",\n\t\t}},\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com\"}); len(matches) != 0 {\n\t\tt.Fatalf(\"普通 Base URL 不应匹配禁止版本前缀的地址: %+v\", matches)\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com/#\"}); len(matches) != 1 {\n\t\tt.Fatalf(\"等效 # 地址匹配数量=%d, want 1\", len(matches))\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyAndMissingRouteToManagedAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [{\"accountUid\":\"acct_existing\",\"channelUid\":\"ch_existing\",\"name\":\"localhost-8990\",\"serviceType\":\"claude\",\"baseUrl\":\"http://localhost:8990\",\"apiKeys\":[\"sk-old\"],\"autoManaged\":true,\"status\":\"active\"}],\n \"chatUpstream\": [], \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"localhost-8990\",\n \"baseUrls\":[\"http://localhost:8990/v1\"],\n \"apiKeys\":[\"sk-new\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"claude-test\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"gpt-test\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d\", len(cfg.Upstream), len(cfg.ResponsesUpstream))\n\t}\n\tif cfg.Upstream[0].AccountUID != \"acct_existing\" || cfg.ResponsesUpstream[0].AccountUID != \"acct_existing\" {\n\t\tt.Fatalf(\"应复用已有账号: messages=%q responses=%q\", cfg.Upstream[0].AccountUID, cfg.ResponsesUpstream[0].AccountUID)\n\t}\n\tif got := strings.Join(cfg.Upstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"messages 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif got := strings.Join(cfg.ResponsesUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"responses 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif cfg.Upstream[0].Name != \"localhost-8990-claude\" || cfg.ResponsesUpstream[0].Name != \"localhost-8990-codex\" {\n\t\tt.Fatalf(\"多协议自动命名错误: messages=%q responses=%q\", cfg.Upstream[0].Name, cfg.ResponsesUpstream[0].Name)\n\t}\n}\n\nfunc TestCustomAutoAddCreatesAllDetectedRoutesWithProtocolModels(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"fastaitoken-com-test\",\n \"baseUrls\":[\"https://example.com/keys\"],\n \"apiKeys\":[\"sk-test\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"shared\",\"messages-only\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"shared\",\"responses-only\"]},\n {\"channelKind\":\"chat\",\"supportedModels\":[\"shared\",\"chat-only\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 3 {\n\t\tt.Fatalf(\"channels=%+v, want three detected routes\", response.Channels)\n\t}\n\tfor _, route := range response.Channels {\n\t\tif route.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route account=%q, want %q\", route.AccountUID, response.AccountUID)\n\t\t}\n\t}\n\tif response.Channels[1].ChannelKind != \"responses\" || response.ChannelUID != response.Channels[1].ChannelUID {\n\t\tt.Fatalf(\"请求协议应作为主路由: response=%+v\", response)\n\t}\n\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 || len(cfg.ChatUpstream) != 1 || len(cfg.GeminiUpstream) != 0 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d chat=%d gemini=%d\",\n\t\t\tlen(cfg.Upstream), len(cfg.ResponsesUpstream), len(cfg.ChatUpstream), len(cfg.GeminiUpstream))\n\t}\n\tchecks := []struct {\n\t\tchannel config.UpstreamConfig\n\t\tname string\n\t\tmodels []string\n\t}{\n\t\t{cfg.Upstream[0], \"fastaitoken-com-test-claude\", []string{\"shared\", \"messages-only\"}},\n\t\t{cfg.ResponsesUpstream[0], \"fastaitoken-com-test-codex\", []string{\"shared\", \"responses-only\"}},\n\t\t{cfg.ChatUpstream[0], \"fastaitoken-com-test-chat\", []string{\"shared\", \"chat-only\"}},\n\t}\n\tfor _, check := range checks {\n\t\tif check.channel.Name != check.name || check.channel.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route identity=%+v, want name=%q account=%q\", check.channel, check.name, response.AccountUID)\n\t\t}\n\t\tif strings.Join(check.channel.SupportedModels, \",\") != strings.Join(check.models, \",\") {\n\t\t\tt.Fatalf(\"route %s models=%v, want %v\", check.name, check.channel.SupportedModels, check.models)\n\t\t}\n\t}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesRenamesAndSyncsCredentials(t *testing.T) {\n\tchannels := []config.AccountChannel{\n\t\t{Kind: \"messages\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-messages\", Name: \"old-claude\",\n\t\t\tServiceType: \"claude\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t\t{Kind: \"responses\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-responses\", Name: \"old-codex\",\n\t\t\tServiceType: \"responses\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t}\n\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\tName: \"renamed\", APIKeys: []string{\"sk-old\", \"sk-new\"},\n\t}, channels, len(channels))\n\tif err != nil || status != http.StatusOK {\n\t\tt.Fatalf(\"status=%d err=%v\", status, err)\n\t}\n\tif len(updates) != 2 || updates[0].Name != \"renamed-claude\" || updates[1].Name != \"renamed-codex\" {\n\t\tt.Fatalf(\"route names=%+v\", updates)\n\t}\n\tfor _, update := range updates {\n\t\tif len(update.APIKeyConfig) != 2 || update.APIKeyConfig[1].BaseURL != \"https://example.com\" {\n\t\t\tt.Fatalf(\"route credentials=%+v\", update.APIKeyConfig)\n\t\t}\n\t\tif update.APIKeyConfig[1].CredentialUID == \"\" {\n\t\t\tt.Fatalf(\"new credential missing stable uid: %+v\", update.APIKeyConfig[1])\n\t\t}\n\t}\n}\n\nfunc TestProviderRouteNameAndPrimaryResult(t *testing.T) {\n\tbase := \"mimo-test\"\n\ttests := []struct {\n\t\troute config.ProviderRoute\n\t\twant string\n\t}{\n\t\t{route: config.ProviderRoute{ChannelKind: \"messages\"}, want: \"mimo-test-claude\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"chat\"}, want: \"mimo-test-chat\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"responses\"}, want: \"mimo-test-codex\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"gemini\"}, want: \"mimo-test-gemini\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := providerRouteName(base, tt.route, true); got != tt.want {\n\t\t\tt.Fatalf(\"providerRouteName(%s)=%q, want %q\", tt.route.ChannelKind, got, tt.want)\n\t\t}\n\t}\n\tif got := providerRouteName(base, config.ProviderRoute{ChannelKind: \"messages\"}, false); got != base {\n\t\tt.Fatalf(\"single route name=%q, want %q\", got, base)\n\t}\n\n\tresults := []AutoAddChannelResult{\n\t\t{ChannelKind: \"messages\", ChannelUID: \"ch_messages\", Index: 1},\n\t\t{ChannelKind: \"chat\", ChannelUID: \"ch_chat\", Index: 2},\n\t\t{ChannelKind: \"responses\", ChannelUID: \"ch_responses\", Index: 3},\n\t}\n\tprimary := primaryAutoAddResult(results, \"chat\")\n\tif primary.ChannelUID != \"ch_chat\" || primary.Index != 2 {\n\t\tt.Fatalf(\"primary=%+v, want chat result\", primary)\n\t}\n}\n\nfunc TestManagedAccountUIDForProviderUsesExistingAccount(t *testing.T) {\n\tcfg := config.Config{ManagedAccounts: []config.ManagedAccountConfig{\n\t\t{AccountUID: \"acct-old\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-new\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-deepseek\", ProviderID: \"deepseek\"},\n\t}}\n\tif got := managedAccountUIDForProvider(cfg, \"mimo\"); got != \"acct-new\" {\n\t\tt.Fatalf(\"mimo existing account = %q, want acct-new\", got)\n\t}\n\tif got := managedAccountUIDForProvider(cfg, \"unknown\"); got != \"\" {\n\t\tt.Fatalf(\"unknown provider 不应命中账号: %q\", got)\n\t}\n}\n\nfunc TestListManagedAccountsDoesNotExposeVolcengineSecret(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-secret\",\"volcengineAccessKey\":{\"accessKeyId\":\"AKIDEXAMPLE\",\"secretAccessKey\":\"SECRET_MUST_NOT_LEAK\",\"plan\":\"agent_plan\",\"planTier\":\"Large\",\"planStatus\":\"Running\"}}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"SECRET_MUST_NOT_LEAK\") || strings.Contains(body, \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", body)\n\t}\n\tfor _, expected := range []string{`\"hasVolcengineAccessKey\":true`, `\"volcenginePlan\":\"agent_plan\"`, `\"volcenginePlanTier\":\"Large\"`} {\n\t\tif !strings.Contains(body, expected) {\n\t\t\tt.Fatalf(\"响应缺少 %s: %s\", expected, body)\n\t\t}\n\t}\n}\n\nfunc TestKimiConsoleTokenLifecycleDoesNotExposeToken(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_kimi\",\"providerId\":\"kimi\",\"name\":\"kimi\",\"credentials\":[{\"credentialUid\":\"cred_kimi\",\"apiKey\":\"sk-kimi-secret\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_kimi\",\"channelUid\":\"ch_kimi\",\"providerId\":\"kimi\",\"name\":\"kimi\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.kimi.com/coding\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_kimi\",\"baseUrl\":\"https://api.kimi.com/coding\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tkimiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Authorization\") != \"Bearer web-session-secret\" {\n\t\t\tt.Fatalf(\"Kimi 请求未使用保存的会话令牌\")\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase kimiUsagesPath:\n\t\t\t_, _ = w.Write([]byte(`{\"usages\":[{\"scope\":\"FEATURE_CODING\",\"detail\":{\"limit\":\"100\",\"remaining\":\"80\",\"resetTime\":\"2026-07-27T16:13:43Z\"},\"limits\":[{\"window\":{\"duration\":300,\"timeUnit\":\"TIME_UNIT_MINUTE\"},\"detail\":{\"limit\":\"100\",\"remaining\":\"90\",\"resetTime\":\"2026-07-21T02:13:43Z\"}}]}],\"totalQuota\":{\"limit\":\"100\",\"remaining\":\"80\"}}`))\n\t\tcase kimiSubscriptionStatsPath:\n\t\t\t_, _ = w.Write([]byte(`{\"ratelimitCode5h\":{\"ratio\":0.1,\"enabled\":true},\"ratelimitCode7d\":{\"ratio\":0.2,\"enabled\":true},\"subscriptionBalance\":{\"feature\":\"FEATURE_OMNI\",\"type\":\"SUBSCRIPTION\",\"unit\":\"UNIT_CREDIT\",\"amountUsedRatio\":0.2,\"kimiCodeUsedRatio\":0.2,\"expireTime\":\"2026-08-20T16:14:07Z\"}}`))\n\t\tdefault:\n\t\t\thttp.NotFound(w, r)\n\t\t}\n\t}))\n\tdefer kimiServer.Close()\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: cfgManager,\n\t\tKimiConsoleClient: &KimiConsoleClient{\n\t\t\tHTTPClient: kimiServer.Client(), BaseURL: kimiServer.URL,\n\t\t},\n\t})\n\tput := httptest.NewRequest(\n\t\thttp.MethodPut,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token\",\n\t\tbytes.NewBufferString(`{\"accessToken\":\"Bearer web-session-secret\"}`),\n\t)\n\tput.Header.Set(\"Content-Type\", \"application/json\")\n\tputRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(putRecorder, put)\n\tif putRecorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"绑定 Kimi 令牌失败: status=%d body=%s\", putRecorder.Code, putRecorder.Body.String())\n\t}\n\tif strings.Contains(putRecorder.Body.String(), \"web-session-secret\") || strings.Contains(putRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"绑定响应泄露 Kimi 令牌: %s\", putRecorder.Body.String())\n\t}\n\tcredential, ok := cfgManager.GetManagedAccountCredential(\"acct_kimi\", \"cred_kimi\")\n\tif !ok || credential.KimiConsole == nil || credential.KimiConsole.AccessToken != \"web-session-secret\" {\n\t\tt.Fatalf(\"Kimi 令牌未持久化: %+v\", credential)\n\t}\n\tpersisted, err := os.ReadFile(configPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !strings.Contains(string(persisted), `\"accessToken\": \"web-session-secret\"`) {\n\t\tt.Fatalf(\"配置文件未保存 Kimi 令牌\")\n\t}\n\n\tlistRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(listRecorder, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif listRecorder.Code != http.StatusOK || strings.Contains(listRecorder.Body.String(), \"web-session-secret\") || strings.Contains(listRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"账号列表响应异常或泄露令牌: status=%d body=%s\", listRecorder.Code, listRecorder.Body.String())\n\t}\n\tif !strings.Contains(listRecorder.Body.String(), `\"hasKimiConsoleToken\":true`) || !strings.Contains(listRecorder.Body.String(), `\"kimiCodeUsage\"`) {\n\t\tt.Fatalf(\"账号列表缺少 Kimi 用量快照: %s\", listRecorder.Body.String())\n\t}\n\n\trefreshRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(refreshRecorder, httptest.NewRequest(\n\t\thttp.MethodPost,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token/refresh\",\n\t\tnil,\n\t))\n\tif refreshRecorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"刷新 Kimi 余量失败: status=%d body=%s\", refreshRecorder.Code, refreshRecorder.Body.String())\n\t}\n\tif strings.Contains(refreshRecorder.Body.String(), \"web-session-secret\") || strings.Contains(refreshRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"刷新响应泄露 Kimi 令牌: %s\", refreshRecorder.Body.String())\n\t}\n\n\tdeleteRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(deleteRecorder, httptest.NewRequest(\n\t\thttp.MethodDelete,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token\",\n\t\tnil,\n\t))\n\tif deleteRecorder.Code != http.StatusNoContent {\n\t\tt.Fatalf(\"解绑 Kimi 令牌失败: status=%d body=%s\", deleteRecorder.Code, deleteRecorder.Body.String())\n\t}\n\tcredential, _ = cfgManager.GetManagedAccountCredential(\"acct_kimi\", \"cred_kimi\")\n\tif credential.KimiConsole != nil {\n\t\tt.Fatalf(\"Kimi 令牌解绑后仍存在: %+v\", credential.KimiConsole)\n\t}\n\tpersisted, err = os.ReadFile(configPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(persisted), \"web-session-secret\") {\n\t\tt.Fatalf(\"解绑后配置文件仍包含 Kimi 令牌\")\n\t}\n}\n\nfunc TestSetVolcengineAccessKeyDetectsPlanBeforePersisting(t *testing.T) {\n\tcontrolPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Query().Get(\"Action\") != \"GetPersonalPlan\" {\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tvar body struct{ Plan string }\n\t\t_ = json.NewDecoder(r.Body).Decode(&body)\n\t\tif body.Plan == \"CodingPlan\" {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t_, _ = w.Write([]byte(`{\"ResponseMetadata\":{\"Error\":{\"Code\":\"ResourceNotFound.Plan\",\"Message\":\"not found\"}}}`))\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\"Result\":{\"PlanType\":\"Large\",\"Status\":\"Running\"}}`))\n\t}))\n\tdefer controlPlane.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-inference\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\trunner := NewAutoDiscoveryRunner(nil, nil)\n\trunner.client = controlPlane.Client()\n\trunner.volcengineControlPlaneEndpoint = controlPlane.URL\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, Runner: runner})\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_volc/credentials/cred_volc/volcengine-access-key\", bytes.NewBufferString(`{\"accessKeyId\":\"AKID\",\"secretAccessKey\":\"SECRET\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"plan\":\"agent_plan\"`) {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_volc\", \"cred_volc\")\n\tif !ok || credential.VolcengineAccessKey == nil || credential.VolcengineAccessKey.SecretAccessKey != \"SECRET\" || credential.VolcengineAccessKey.Plan != \"agent_plan\" {\n\t\tt.Fatalf(\"凭证未正确持久化: %+v\", credential)\n\t}\n}\n\nfunc TestSetMiMoConsoleCookieRequiresConfirmationBeforeAdoptingKey(t *testing.T) {\n\tconst oldKey = \"tp-old-1234567890123456789012345678901234567890\"\n\tconst cookieKey = \"tp-new-1234567890123456789012345678901234567890\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != \"api-platform_serviceToken=session; userId=42\" {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase \"/api/v1/userProfile\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"userId\":\"42\"}}`))\n\t\tcase \"/api/v1/tokenPlan/detail\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"planCode\":\"max\",\"planName\":\"Max\",\"currentPeriodEnd\":\"2026-07-29 23:59:59\"}}`))\n\t\tcase \"/api/v1/tokenPlan/usage\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"monthUsage\":{\"percent\":0.25,\"items\":[{\"used\":25,\"limit\":100}]},\"usage\":{\"percent\":0.5,\"items\":[{\"used\":50,\"limit\":100}]}}}`))\n\t\tcase \"/api/v1/tokenPlan/apiKey/raw\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":\"` + cookieKey + `\"}`))\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}\n\t}))\n\tdefer console.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"credentials\":[{\"credentialUid\":\"cred_mimo\",\"apiKey\":\"` + oldKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_mimo\",\"channelUid\":\"ch_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_mimo\",\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\tdeps := &AutoManagedDeps{CfgManager: manager, MiMoConsoleClient: &MiMoConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL}}\n\trouter := setupAutoManagedRouter(deps)\n\tbody := `{\"cookie\":\"api-platform_serviceToken=session; userId=42\"}`\n\trequest := func(payload string) *httptest.ResponseRecorder {\n\t\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_mimo/credentials/cred_mimo/mimo-console-cookie\", bytes.NewBufferString(payload))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tw := httptest.NewRecorder()\n\t\trouter.ServeHTTP(w, req)\n\t\treturn w\n\t}\n\tw := request(body)\n\tif w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), `\"code\":\"mimo_cookie_key_mismatch\"`) {\n\t\tt.Fatalf(\"未要求确认: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ := manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != oldKey || credential.MiMoConsole != nil {\n\t\tt.Fatalf(\"确认前不应修改配置: %+v\", credential)\n\t}\n\tw = request(`{\"cookie\":\"api-platform_serviceToken=session; userId=42\",\"adoptCookieKey\":true}`)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"keyAdopted\":true`) {\n\t\tt.Fatalf(\"确认采用失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ = manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != cookieKey || credential.MiMoConsole == nil {\n\t\tt.Fatalf(\"确认后未采用 Cookie Key: %+v\", credential)\n\t}\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif strings.Contains(list.Body.String(), \"api-platform_serviceToken\") || strings.Contains(list.Body.String(), cookieKey) {\n\t\tt.Fatalf(\"账号列表泄漏 Cookie 或原始 Key: %s\", list.Body.String())\n\t}\n}\n\nfunc TestCompshareConsoleCookieBindingDoesNotExposeSecrets(t *testing.T) {\n\tconst currentKey = \"sk-cp-test-current\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != testCompshareCookie {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\n \"RetCode\": 0,\n \"UserPlans\": [{\n \"Code\": \"cppkg-test\", \"PlanCode\": \"cp-plan-test\", \"PlanName\": \"Pro\", \"DisplayName\": \"Pro 增强版\",\n \"LimitPer5h\": 3000, \"LimitPerWeek\": 7500, \"LimitPerMonth\": 19000,\n \"UsagePer5h\": 0, \"UsagePerWeek\": 100, \"UsagePerMonth\": 6496,\n \"ConcurrencyLimit\": 10, \"Status\": 1, \"ExpireAt\": 1785037981,\n \"Keys\": [{\"APIKey\": \"` + currentKey + `\"}]\n }]\n}`))\n\t}))\n\tdefer console.Close()\n\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_compshare\",\"providerId\":\"compshare\",\"name\":\"compshare\",\"credentials\":[{\"credentialUid\":\"cred_compshare\",\"apiKey\":\"` + currentKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_compshare\",\"channelUid\":\"ch_compshare\",\"providerId\":\"compshare\",\"name\":\"compshare\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://api.compshare.cn/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_compshare\",\"baseUrl\":\"https://api.compshare.cn/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: manager,\n\t\tCompshareConsoleClient: &CompshareConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL},\n\t})\n\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_compshare/credentials/cred_compshare/compshare-console-cookie\", bytes.NewBufferString(`{\"cookie\":\"`+testCompshareCookie+`\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"displayName\":\"Pro 增强版\"`) {\n\t\tt.Fatalf(\"绑定失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_compshare\", \"cred_compshare\")\n\tif !ok || credential.CompshareConsole == nil || credential.CompshareConsole.Cookie != testCompshareCookie {\n\t\tt.Fatalf(\"控制台套餐未持久化: %+v\", credential)\n\t}\n\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tresponse := list.Body.String()\n\tfor _, secret := range []string{\"U_JWT_TOKEN\", \"test-session\", \"test_user@console.compshare.cn\", \"org-test\", currentKey, \"cppkg-test\"} {\n\t\tif strings.Contains(response, secret) {\n\t\t\tt.Fatalf(\"账号列表泄漏敏感字段 %q: %s\", secret, response)\n\t\t}\n\t}\n\tfor _, expected := range []string{`\"hasCompshareConsoleCookie\":true`, `\"planCode\":\"cp-plan-test\"`, `\"monthlyUsage\":{\"used\":6496,\"limit\":19000`} {\n\t\tif !strings.Contains(response, expected) {\n\t\t\tt.Fatalf(\"账号列表缺少套餐字段 %s: %s\", expected, response)\n\t\t}\n\t}\n}\n\nfunc TestProviderAutoAddReusesExistingAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(`{\"providerId\":\"mimo\",\"apiKeys\":[\"sk-existing\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"追加已有 provider key status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 4 {\n\t\tt.Fatalf(\"旧账号应自动补齐模板中的协议渠道: %+v\", channels)\n\t}\n\tkinds := make(map[string]bool, len(channels))\n\tfor _, channel := range channels {\n\t\tkinds[channel.Kind] = true\n\t}\n\tfor _, kind := range []string{\"messages\", \"chat\", \"responses\", \"gemini\"} {\n\t\tif !kinds[kind] {\n\t\t\tt.Fatalf(\"旧账号缺少 %s route: %+v\", kind, channels)\n\t\t}\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.AccountUID != \"acct_test\" {\n\t\tt.Fatalf(\"响应未返回已有账号: body=%s err=%v\", w.Body.String(), err)\n\t}\n\tif len(response.Channels) != 4 {\n\t\tt.Fatalf(\"响应未返回补齐后的全部渠道: %+v\", response.Channels)\n\t}\n}\n\nfunc TestMissingProviderAccountRoutes(t *testing.T) {\n\ttmpl, ok := config.GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"缺少 deepseek provider 模板\")\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{ServiceType: \"claude\"},\n\t}}\n\tmissing := missingProviderAccountRoutes(tmpl, existing)\n\tif len(missing) != 2 || missing[0].ChannelKind != \"chat\" || missing[1].ChannelKind != \"responses\" {\n\t\tt.Fatalf(\"missing routes = %+v\", missing)\n\t}\n\tconfigs, baseURLs, err := bindProviderRouteKeys(tmpl, missing[0], []string{\"sk-existing\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(configs) != 1 || configs[0].BaseURL != \"https://api.deepseek.com\" || len(baseURLs) != 1 {\n\t\tt.Fatalf(\"route 绑定结果不正确: configs=%+v baseURLs=%v\", configs, baseURLs)\n\t}\n}\n\nfunc TestBindProviderRouteKeysPreservesExistingEndpointAffinity(t *testing.T) {\n\ttests := []struct {\n\t\tproviderID string\n\t\tapiKey string\n\t\texistingBaseURL string\n\t\ttargetKind string\n\t\twantBaseURL string\n\t}{\n\t\t{\n\t\t\tproviderID: \"mimo\", apiKey: \"tp-existing\",\n\t\t\texistingBaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\t\ttargetKind: \"chat\", wantBaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\",\n\t\t},\n\t\t{\n\t\t\tproviderID: \"volcengine\", apiKey: \"ark-existing\",\n\t\t\texistingBaseURL: \"https://ark.cn-beijing.volces.com/api/coding\",\n\t\t\ttargetKind: \"responses\", wantBaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.providerID, func(t *testing.T) {\n\t\t\ttmpl, ok := config.GetProviderTemplate(tt.providerID)\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"缺少 provider 模板: %s\", tt.providerID)\n\t\t\t}\n\t\t\texisting := []config.AccountChannel{{\n\t\t\t\tKind: \"messages\",\n\t\t\t\tUpstream: config.UpstreamConfig{\n\t\t\t\t\tProviderID: tt.providerID, ServiceType: \"claude\", APIKeys: []string{tt.apiKey},\n\t\t\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: tt.apiKey, BaseURL: tt.existingBaseURL}},\n\t\t\t\t},\n\t\t\t}}\n\t\t\tvar target config.ProviderRoute\n\t\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\t\tif route.ChannelKind == tt.targetKind {\n\t\t\t\t\ttarget = route\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tconfigs, _, err := bindProviderRouteKeysWithAffinities(\n\t\t\t\ttmpl, target, []string{tt.apiKey}, providerKeyCandidateAffinities(tmpl, existing),\n\t\t\t)\n\t\t\tif err != nil || len(configs) != 1 || configs[0].BaseURL != tt.wantBaseURL {\n\t\t\t\tt.Fatalf(\"亲和绑定错误: configs=%+v err=%v want=%s\", configs, err, tt.wantBaseURL)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPlanProviderAccountRouteAdditionsRejectsNewKeyBeforeMutation(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t}))\n\tdefer server.Close()\n\n\ttmpl := &config.ProviderTemplate{\n\t\tProviderID: \"test-provider\",\n\t\tRoutes: []config.ProviderRoute{\n\t\t\t{ChannelKind: \"messages\", ServiceType: \"claude\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/anthropic\"}}},\n\t\t\t{ChannelKind: \"chat\", ServiceType: \"openai\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/v1\"}}},\n\t\t},\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct_test\", ChannelUID: \"ch_messages\", Name: \"test-provider\",\n\t\t\tProviderID: \"test-provider\", ServiceType: \"claude\", AutoManaged: true, APIKeys: []string{\"sk-existing\"},\n\t\t},\n\t}}\n\tadditions, status, err := planProviderAccountRouteAdditions(\n\t\tt.Context(), config.Config{}, tmpl, \"acct_test\", []string{\"sk-existing\", \"sk-invalid\"}, existing,\n\t)\n\tif err == nil || status != http.StatusBadRequest || len(additions) != 0 {\n\t\tt.Fatalf(\"无效新 Key 应在生成新增 route 前失败: status=%d additions=%+v err=%v\", status, additions, err)\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}, "1cb470c6bb1816aef002e50e1facc79781277209": {"t": 1785759343.9363415, "n": 52382, "h": "95073f75ec692642", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestListAccountsMasksCredentials(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-secret-value\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil)\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"sk-secret-value\") {\n\t\tt.Fatalf(\"账号列表泄露明文 Key: %s\", body)\n\t}\n\tif !strings.Contains(body, \"cred_test\") || !strings.Contains(body, \"keyMask\") {\n\t\tt.Fatalf(\"账号列表缺少凭证掩码信息: %s\", body)\n\t}\n}\n\nfunc TestListAccountsIncludesActiveEndpointModelAvailability(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"volcengine\",\"name\":\"volcengine\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"ark-secret-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"ark-secret-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"volcengine\",\"name\":\"volcengine-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://example.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tstore, err := NewProfileStoreWithDB(newTestDB(t))\n\tif err != nil {\n\t\tt.Fatalf(\"NewProfileStoreWithDB 失败: %v\", err)\n\t}\n\tupdatedAt := time.Date(2026, 7, 17, 10, 54, 33, 0, time.UTC)\n\tfor _, profile := range []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_a\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_a\", KeyMask: \"ark-a***001\", AvailableModels: []string{\"model-b\", \"model-a\"}, UpdatedAt: updatedAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_b\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_b\", KeyMask: \"ark-b***002\", AvailableModels: []string{\"model-c\", \"model-b\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t},\n\t} {\n\t\tif err := store.Upsert(profile); err != nil {\n\t\t\tt.Fatalf(\"ProfileStore.Upsert 失败: %v\", err)\n\t\t}\n\t}\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager, Runner: runner})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response struct {\n\t\tAccounts []managedAccountView `json:\"accounts\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatalf(\"解析响应失败: %v\", err)\n\t}\n\tif len(response.Accounts) != 1 || len(response.Accounts[0].Channels) != 1 {\n\t\tt.Fatalf(\"账号渠道数量错误: %+v\", response.Accounts)\n\t}\n\tchannel := response.Accounts[0].Channels[0]\n\tif got, want := strings.Join(channel.DiscoveredModels, \",\"), \"model-a,model-b,model-c\"; got != want {\n\t\tt.Fatalf(\"DiscoveredModels=%q, want %q\", got, want)\n\t}\n\tif !channel.ModelInventoryKnown {\n\t\tt.Fatal(\"已发现模型时 ModelInventoryKnown 应为 true\")\n\t}\n\tif len(channel.ModelBindings) != 2 {\n\t\tt.Fatalf(\"ModelBindings=%+v, want 2\", channel.ModelBindings)\n\t}\n\tif got := strings.Join(channel.ModelBindings[0].Models, \",\"); got != \"model-a,model-b\" {\n\t\tt.Fatalf(\"第一个 Key 模型=%q\", got)\n\t}\n\tif channel.ModelsUpdatedAt == nil || !channel.ModelsUpdatedAt.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"ModelsUpdatedAt=%v\", channel.ModelsUpdatedAt)\n\t}\n\tif strings.Contains(w.Body.String(), \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", w.Body.String())\n\t}\n}\n\nfunc TestManagedChannelModelAvailabilityIncludesEmptyAutoDiscoveryResult(t *testing.T) {\n\tupdatedAt := time.Date(2026, 7, 17, 11, 2, 0, 0, time.UTC)\n\tmodels, bindings, latest, known := managedChannelModelAvailability([]*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_empty\", CredentialUID: \"cred_empty\", KeyMask: \"ark-e***001\",\n\t\t\tSource: \"auto_discovery\", AvailableModels: []string{}, UpdatedAt: updatedAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_models\", CredentialUID: \"cred_models\", KeyMask: \"ark-m***002\",\n\t\t\tAvailableModels: []string{\"model-a\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_unknown\", CredentialUID: \"cred_unknown\", KeyMask: \"ark-u***003\",\n\t\t\tSource: \"l1_passive\", UpdatedAt: updatedAt.Add(2 * time.Minute),\n\t\t},\n\t})\n\n\tif !known {\n\t\tt.Fatal(\"自动发现的空模型清单仍应标记为已知\")\n\t}\n\tif got, want := strings.Join(models, \",\"), \"model-a\"; got != want {\n\t\tt.Fatalf(\"models=%q, want %q\", got, want)\n\t}\n\tif len(bindings) != 2 {\n\t\tt.Fatalf(\"bindings=%+v, want only two known inventories\", bindings)\n\t}\n\tif got := bindings[0]; got.KeyMask != \"ark-e***001\" || len(got.Models) != 0 {\n\t\tt.Fatalf(\"empty discovery binding=%+v, want empty ark-e binding\", got)\n\t}\n\tif latest == nil || !latest.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"latest=%v, want discovered inventory update time\", latest)\n\t}\n}\n\nfunc TestPatchAccountCredentialsRemovesByUID(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"sk-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"sk-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"baseUrls\":[\"https://api.xiaomimimo.com/anthropic\"],\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPatch, \"/api/accounts/acct_test/credentials\", bytes.NewBufferString(`{\"removeCredentialUids\":[\"cred_b\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PATCH credentials status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 1 || len(channels[0].Upstream.APIKeys) != 1 || channels[0].Upstream.APIKeys[0] != \"sk-a\" {\n\t\tt.Fatalf(\"按 credentialUid 删除失败: %+v\", channels)\n\t}\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestInferAutoAddProviderID(t *testing.T) {\n\tzhipuKey := \"0123456789abcdef0123456789abcdef.ABCDEFGHIJKLMNO1\"\n\ttests := []struct {\n\t\tname string\n\t\tbaseURLs []string\n\t\tapiKeys []string\n\t\twant string\n\t}{\n\t\t{name: \"官方 Claude URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"官方 OpenAI URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4/\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"两个官方协议 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\", \"https://open.bigmodel.cn/api/paas/v4\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"仅智谱 Key\", apiKeys: []string{zhipuKey}, want: \"glm\"},\n\t\t{name: \"第三方 URL 不按 Key 提升\", baseURLs: []string{\"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"混合官方与第三方 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4\", \"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"共享 sk Key 无法推断\", apiKeys: []string{\"sk-abcdefghijklmnopqrstuvwxyz123456\"}},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := inferAutoAddProviderID(tt.baseURLs, tt.apiKeys); got != tt.want {\n\t\t\t\tt.Fatalf(\"inferAutoAddProviderID() = %q, want %q\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestApplyProviderUpstreamDefaults(t *testing.T) {\n\tglmChat := config.UpstreamConfig{ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmChat)\n\tif glmChat.ReasoningParamStyle != \"reasoning_effort\" || !glmChat.PassbackReasoningContent {\n\t\tt.Fatalf(\"GLM OpenAI 默认兼容参数未补齐: %+v\", glmChat)\n\t}\n\n\tglmClaude := config.UpstreamConfig{ServiceType: \"claude\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmClaude)\n\tif glmClaude.ReasoningParamStyle != \"\" || glmClaude.PassbackReasoningContent {\n\t\tt.Fatalf(\"GLM Claude 原生 route 不应注入 OpenAI 参数: %+v\", glmClaude)\n\t}\n\n\tcustom := config.UpstreamConfig{ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"\", &custom)\n\tif custom.ReasoningParamStyle != \"\" || custom.PassbackReasoningContent {\n\t\tt.Fatalf(\"自定义渠道不应应用 GLM 默认值: %+v\", custom)\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCustomAutoAddResponseIncludesActualRoute(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\tdiscoverer := NewRateLimitDiscoverer(RateLimitDiscovererConfig{QuietLogs: true})\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, RateLimitDiscoverer: discoverer})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"fastaitoken-com-test\",\"baseUrls\":[\"https://example.com\"],\"apiKeys\":[\"sk-test\"],\"rateLimitHint\":{\"initialRpm\":30,\"effectiveRpm\":15,\"rateLimited\":true,\"rateLimitedCount\":1}}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 {\n\t\tt.Fatalf(\"channels=%+v, want one actual route\", response.Channels)\n\t}\n\troute := response.Channels[0]\n\tif route.ChannelKind != \"responses\" || route.ServiceType != \"responses\" || route.ChannelUID != response.ChannelUID {\n\t\tt.Fatalf(\"route=%+v response=%+v\", route, response)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.ResponsesUpstream) != 1 || len(cfg.Upstream) != 0 {\n\t\tt.Fatalf(\"custom route persisted in wrong channel: responses=%d messages=%d\", len(cfg.ResponsesUpstream), len(cfg.Upstream))\n\t}\n\tcreated := cfg.ResponsesUpstream[0]\n\tbaseURL := created.GetEffectiveBaseURL()\n\tendpointUID := GenerateEndpointUID(created.ChannelUID, baseURL, KeyHashFromAPIKey(\"sk-test\"))\n\tif suggestion := discoverer.SuggestedLimit(endpointUID); suggestion.RPM != 15 || suggestion.Confidence != 0.7 {\n\t\tt.Fatalf(\"rate limit suggestion=%+v, want rpm=15 confidence=0.7\", suggestion)\n\t}\n\n\tstore := newTestProfileStore(t)\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\tseedAutoAddRateLimitHint(\n\t\t&AutoManagedDeps{CfgManager: manager, Runner: runner, RateLimitDiscoverer: discoverer},\n\t\tmanager.GetAccountChannels(response.AccountUID),\n\t\t&AutoAddRateLimitHint{InitialRPM: 30, EffectiveRPM: 15, RateLimited: true, RateLimitedCount: 1},\n\t\t\"sk-test\",\n\t\t\"https://example.com\",\n\t)\n\tprofile := store.Get(endpointUID)\n\tif profile == nil || profile.DiscoveredRPM != 15 || profile.RateLimitConfidence != 0.7 || profile.RateLimitSource != string(RateLimitSourcePassiveAIMD) {\n\t\tt.Fatalf(\"rate limit profile=%+v\", profile)\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyToEquivalentExistingBaseURL(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [],\n \"chatUpstream\": [{\"channelUid\":\"ch_existing\",\"name\":\"localhost-old-name\",\"serviceType\":\"openai\",\"baseUrl\":\"http://localhost:8990/v1\",\"apiKeys\":[\"sk-old\"],\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"localhost-8990\",\"baseUrls\":[\"http://localhost:8990/\"],\"apiKeys\":[\"sk-new\"]}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 || response.Channels[0].Name != \"localhost-old-name\" {\n\t\tt.Fatalf(\"应返回已有渠道: %+v\", response.Channels)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 0 || len(cfg.ChatUpstream) != 1 {\n\t\tt.Fatalf(\"重复请求不应创建新渠道: messages=%d chat=%d\", len(cfg.Upstream), len(cfg.ChatUpstream))\n\t}\n\tif got := strings.Join(cfg.ChatUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"已有渠道密钥=%q, want sk-old,sk-new\", got)\n\t}\n}\n\nfunc TestFindExistingAutoAddChannelsPreservesHashSemantics(t *testing.T) {\n\tcfg := config.Config{\n\t\tChatUpstream: []config.UpstreamConfig{{\n\t\t\tChannelUID: \"ch-hash\", ServiceType: \"openai\", BaseURL: \"https://example.com#\",\n\t\t}},\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com\"}); len(matches) != 0 {\n\t\tt.Fatalf(\"普通 Base URL 不应匹配禁止版本前缀的地址: %+v\", matches)\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com/#\"}); len(matches) != 1 {\n\t\tt.Fatalf(\"等效 # 地址匹配数量=%d, want 1\", len(matches))\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyAndMissingRouteToManagedAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [{\"accountUid\":\"acct_existing\",\"channelUid\":\"ch_existing\",\"name\":\"localhost-8990\",\"serviceType\":\"claude\",\"baseUrl\":\"http://localhost:8990\",\"apiKeys\":[\"sk-old\"],\"autoManaged\":true,\"status\":\"active\"}],\n \"chatUpstream\": [], \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"localhost-8990\",\n \"baseUrls\":[\"http://localhost:8990/v1\"],\n \"apiKeys\":[\"sk-new\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"claude-test\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"gpt-test\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d\", len(cfg.Upstream), len(cfg.ResponsesUpstream))\n\t}\n\tif cfg.Upstream[0].AccountUID != \"acct_existing\" || cfg.ResponsesUpstream[0].AccountUID != \"acct_existing\" {\n\t\tt.Fatalf(\"应复用已有账号: messages=%q responses=%q\", cfg.Upstream[0].AccountUID, cfg.ResponsesUpstream[0].AccountUID)\n\t}\n\tif got := strings.Join(cfg.Upstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"messages 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif got := strings.Join(cfg.ResponsesUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"responses 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif cfg.Upstream[0].Name != \"localhost-8990-claude\" || cfg.ResponsesUpstream[0].Name != \"localhost-8990-codex\" {\n\t\tt.Fatalf(\"多协议自动命名错误: messages=%q responses=%q\", cfg.Upstream[0].Name, cfg.ResponsesUpstream[0].Name)\n\t}\n}\n\nfunc TestCustomAutoAddCreatesAllDetectedRoutesWithProtocolModels(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"fastaitoken-com-test\",\n \"baseUrls\":[\"https://example.com/keys\"],\n \"apiKeys\":[\"sk-test\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"shared\",\"messages-only\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"shared\",\"responses-only\"]},\n {\"channelKind\":\"chat\",\"supportedModels\":[\"shared\",\"chat-only\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 3 {\n\t\tt.Fatalf(\"channels=%+v, want three detected routes\", response.Channels)\n\t}\n\tfor _, route := range response.Channels {\n\t\tif route.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route account=%q, want %q\", route.AccountUID, response.AccountUID)\n\t\t}\n\t}\n\tif response.Channels[1].ChannelKind != \"responses\" || response.ChannelUID != response.Channels[1].ChannelUID {\n\t\tt.Fatalf(\"请求协议应作为主路由: response=%+v\", response)\n\t}\n\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 || len(cfg.ChatUpstream) != 1 || len(cfg.GeminiUpstream) != 0 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d chat=%d gemini=%d\",\n\t\t\tlen(cfg.Upstream), len(cfg.ResponsesUpstream), len(cfg.ChatUpstream), len(cfg.GeminiUpstream))\n\t}\n\tchecks := []struct {\n\t\tchannel config.UpstreamConfig\n\t\tname string\n\t\tmodels []string\n\t}{\n\t\t{cfg.Upstream[0], \"fastaitoken-com-test-claude\", []string{\"shared\", \"messages-only\"}},\n\t\t{cfg.ResponsesUpstream[0], \"fastaitoken-com-test-codex\", []string{\"shared\", \"responses-only\"}},\n\t\t{cfg.ChatUpstream[0], \"fastaitoken-com-test-chat\", []string{\"shared\", \"chat-only\"}},\n\t}\n\tfor _, check := range checks {\n\t\tif check.channel.Name != check.name || check.channel.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route identity=%+v, want name=%q account=%q\", check.channel, check.name, response.AccountUID)\n\t\t}\n\t\tif strings.Join(check.channel.SupportedModels, \",\") != strings.Join(check.models, \",\") {\n\t\t\tt.Fatalf(\"route %s models=%v, want %v\", check.name, check.channel.SupportedModels, check.models)\n\t\t}\n\t}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesRenamesAndSyncsCredentials(t *testing.T) {\n\tchannels := []config.AccountChannel{\n\t\t{Kind: \"messages\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-messages\", Name: \"old-claude\",\n\t\t\tServiceType: \"claude\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t\t{Kind: \"responses\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-responses\", Name: \"old-codex\",\n\t\t\tServiceType: \"responses\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t}\n\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\tName: \"renamed\", APIKeys: []string{\"sk-old\", \"sk-new\"},\n\t}, channels, len(channels))\n\tif err != nil || status != http.StatusOK {\n\t\tt.Fatalf(\"status=%d err=%v\", status, err)\n\t}\n\tif len(updates) != 2 || updates[0].Name != \"renamed-claude\" || updates[1].Name != \"renamed-codex\" {\n\t\tt.Fatalf(\"route names=%+v\", updates)\n\t}\n\tfor _, update := range updates {\n\t\tif len(update.APIKeyConfig) != 2 || update.APIKeyConfig[1].BaseURL != \"https://example.com\" {\n\t\t\tt.Fatalf(\"route credentials=%+v\", update.APIKeyConfig)\n\t\t}\n\t\tif update.APIKeyConfig[1].CredentialUID == \"\" {\n\t\t\tt.Fatalf(\"new credential missing stable uid: %+v\", update.APIKeyConfig[1])\n\t\t}\n\t}\n}\n\nfunc TestProviderRouteNameAndPrimaryResult(t *testing.T) {\n\tbase := \"mimo-test\"\n\ttests := []struct {\n\t\troute config.ProviderRoute\n\t\twant string\n\t}{\n\t\t{route: config.ProviderRoute{ChannelKind: \"messages\"}, want: \"mimo-test-claude\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"chat\"}, want: \"mimo-test-chat\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"responses\"}, want: \"mimo-test-codex\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"gemini\"}, want: \"mimo-test-gemini\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := providerRouteName(base, tt.route, true); got != tt.want {\n\t\t\tt.Fatalf(\"providerRouteName(%s)=%q, want %q\", tt.route.ChannelKind, got, tt.want)\n\t\t}\n\t}\n\tif got := providerRouteName(base, config.ProviderRoute{ChannelKind: \"messages\"}, false); got != base {\n\t\tt.Fatalf(\"single route name=%q, want %q\", got, base)\n\t}\n\n\tresults := []AutoAddChannelResult{\n\t\t{ChannelKind: \"messages\", ChannelUID: \"ch_messages\", Index: 1},\n\t\t{ChannelKind: \"chat\", ChannelUID: \"ch_chat\", Index: 2},\n\t\t{ChannelKind: \"responses\", ChannelUID: \"ch_responses\", Index: 3},\n\t}\n\tprimary := primaryAutoAddResult(results, \"chat\")\n\tif primary.ChannelUID != \"ch_chat\" || primary.Index != 2 {\n\t\tt.Fatalf(\"primary=%+v, want chat result\", primary)\n\t}\n}\n\nfunc TestManagedAccountUIDForProviderUsesExistingAccount(t *testing.T) {\n\tcfg := config.Config{ManagedAccounts: []config.ManagedAccountConfig{\n\t\t{AccountUID: \"acct-old\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-new\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-deepseek\", ProviderID: \"deepseek\"},\n\t}}\n\tif got := managedAccountUIDForProvider(cfg, \"mimo\"); got != \"acct-new\" {\n\t\tt.Fatalf(\"mimo existing account = %q, want acct-new\", got)\n\t}\n\tif got := managedAccountUIDForProvider(cfg, \"unknown\"); got != \"\" {\n\t\tt.Fatalf(\"unknown provider 不应命中账号: %q\", got)\n\t}\n}\n\nfunc TestListManagedAccountsDoesNotExposeVolcengineSecret(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-secret\",\"volcengineAccessKey\":{\"accessKeyId\":\"AKIDEXAMPLE\",\"secretAccessKey\":\"SECRET_MUST_NOT_LEAK\",\"plan\":\"agent_plan\",\"planTier\":\"Large\",\"planStatus\":\"Running\"}}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"SECRET_MUST_NOT_LEAK\") || strings.Contains(body, \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", body)\n\t}\n\tfor _, expected := range []string{`\"hasVolcengineAccessKey\":true`, `\"volcenginePlan\":\"agent_plan\"`, `\"volcenginePlanTier\":\"Large\"`} {\n\t\tif !strings.Contains(body, expected) {\n\t\t\tt.Fatalf(\"响应缺少 %s: %s\", expected, body)\n\t\t}\n\t}\n}\n\nfunc TestKimiConsoleTokenLifecycleDoesNotExposeToken(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_kimi\",\"providerId\":\"kimi\",\"name\":\"kimi\",\"credentials\":[{\"credentialUid\":\"cred_kimi\",\"apiKey\":\"sk-kimi-secret\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_kimi\",\"channelUid\":\"ch_kimi\",\"providerId\":\"kimi\",\"name\":\"kimi\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.kimi.com/coding\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_kimi\",\"baseUrl\":\"https://api.kimi.com/coding\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tkimiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Authorization\") != \"Bearer web-session-secret\" {\n\t\t\tt.Fatalf(\"Kimi 请求未使用保存的会话令牌\")\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase kimiUsagesPath:\n\t\t\t_, _ = w.Write([]byte(`{\"usages\":[{\"scope\":\"FEATURE_CODING\",\"detail\":{\"limit\":\"100\",\"remaining\":\"80\",\"resetTime\":\"2026-07-27T16:13:43Z\"},\"limits\":[{\"window\":{\"duration\":300,\"timeUnit\":\"TIME_UNIT_MINUTE\"},\"detail\":{\"limit\":\"100\",\"remaining\":\"90\",\"resetTime\":\"2026-07-21T02:13:43Z\"}}]}],\"totalQuota\":{\"limit\":\"100\",\"remaining\":\"80\"}}`))\n\t\tcase kimiSubscriptionStatsPath:\n\t\t\t_, _ = w.Write([]byte(`{\"ratelimitCode5h\":{\"ratio\":0.1,\"enabled\":true},\"ratelimitCode7d\":{\"ratio\":0.2,\"enabled\":true},\"subscriptionBalance\":{\"feature\":\"FEATURE_OMNI\",\"type\":\"SUBSCRIPTION\",\"unit\":\"UNIT_CREDIT\",\"amountUsedRatio\":0.2,\"kimiCodeUsedRatio\":0.2,\"expireTime\":\"2026-08-20T16:14:07Z\"}}`))\n\t\tdefault:\n\t\t\thttp.NotFound(w, r)\n\t\t}\n\t}))\n\tdefer kimiServer.Close()\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: cfgManager,\n\t\tKimiConsoleClient: &KimiConsoleClient{\n\t\t\tHTTPClient: kimiServer.Client(), BaseURL: kimiServer.URL,\n\t\t},\n\t})\n\tput := httptest.NewRequest(\n\t\thttp.MethodPut,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token\",\n\t\tbytes.NewBufferString(`{\"accessToken\":\"Bearer web-session-secret\"}`),\n\t)\n\tput.Header.Set(\"Content-Type\", \"application/json\")\n\tputRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(putRecorder, put)\n\tif putRecorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"绑定 Kimi 令牌失败: status=%d body=%s\", putRecorder.Code, putRecorder.Body.String())\n\t}\n\tif strings.Contains(putRecorder.Body.String(), \"web-session-secret\") || strings.Contains(putRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"绑定响应泄露 Kimi 令牌: %s\", putRecorder.Body.String())\n\t}\n\tcredential, ok := cfgManager.GetManagedAccountCredential(\"acct_kimi\", \"cred_kimi\")\n\tif !ok || credential.KimiConsole == nil || credential.KimiConsole.AccessToken != \"web-session-secret\" {\n\t\tt.Fatalf(\"Kimi 令牌未持久化: %+v\", credential)\n\t}\n\tpersisted, err := os.ReadFile(configPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !strings.Contains(string(persisted), `\"accessToken\": \"web-session-secret\"`) {\n\t\tt.Fatalf(\"配置文件未保存 Kimi 令牌\")\n\t}\n\n\tlistRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(listRecorder, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif listRecorder.Code != http.StatusOK || strings.Contains(listRecorder.Body.String(), \"web-session-secret\") || strings.Contains(listRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"账号列表响应异常或泄露令牌: status=%d body=%s\", listRecorder.Code, listRecorder.Body.String())\n\t}\n\tif !strings.Contains(listRecorder.Body.String(), `\"hasKimiConsoleToken\":true`) || !strings.Contains(listRecorder.Body.String(), `\"kimiCodeUsage\"`) {\n\t\tt.Fatalf(\"账号列表缺少 Kimi 用量快照: %s\", listRecorder.Body.String())\n\t}\n\n\trefreshRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(refreshRecorder, httptest.NewRequest(\n\t\thttp.MethodPost,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token/refresh\",\n\t\tnil,\n\t))\n\tif refreshRecorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"刷新 Kimi 余量失败: status=%d body=%s\", refreshRecorder.Code, refreshRecorder.Body.String())\n\t}\n\tif strings.Contains(refreshRecorder.Body.String(), \"web-session-secret\") || strings.Contains(refreshRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"刷新响应泄露 Kimi 令牌: %s\", refreshRecorder.Body.String())\n\t}\n\n\tdeleteRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(deleteRecorder, httptest.NewRequest(\n\t\thttp.MethodDelete,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token\",\n\t\tnil,\n\t))\n\tif deleteRecorder.Code != http.StatusNoContent {\n\t\tt.Fatalf(\"解绑 Kimi 令牌失败: status=%d body=%s\", deleteRecorder.Code, deleteRecorder.Body.String())\n\t}\n\tcredential, _ = cfgManager.GetManagedAccountCredential(\"acct_kimi\", \"cred_kimi\")\n\tif credential.KimiConsole != nil {\n\t\tt.Fatalf(\"Kimi 令牌解绑后仍存在: %+v\", credential.KimiConsole)\n\t}\n\tpersisted, err = os.ReadFile(configPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(persisted), \"web-session-secret\") {\n\t\tt.Fatalf(\"解绑后配置文件仍包含 Kimi 令牌\")\n\t}\n}\n\nfunc TestSetVolcengineAccessKeyDetectsPlanBeforePersisting(t *testing.T) {\n\tcontrolPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Query().Get(\"Action\") != \"GetPersonalPlan\" {\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tvar body struct{ Plan string }\n\t\t_ = json.NewDecoder(r.Body).Decode(&body)\n\t\tif body.Plan == \"CodingPlan\" {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t_, _ = w.Write([]byte(`{\"ResponseMetadata\":{\"Error\":{\"Code\":\"ResourceNotFound.Plan\",\"Message\":\"not found\"}}}`))\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\"Result\":{\"PlanType\":\"Large\",\"Status\":\"Running\"}}`))\n\t}))\n\tdefer controlPlane.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-inference\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\trunner := NewAutoDiscoveryRunner(nil, nil)\n\trunner.client = controlPlane.Client()\n\trunner.volcengineControlPlaneEndpoint = controlPlane.URL\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, Runner: runner})\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_volc/credentials/cred_volc/volcengine-access-key\", bytes.NewBufferString(`{\"accessKeyId\":\"AKID\",\"secretAccessKey\":\"SECRET\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"plan\":\"agent_plan\"`) {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_volc\", \"cred_volc\")\n\tif !ok || credential.VolcengineAccessKey == nil || credential.VolcengineAccessKey.SecretAccessKey != \"SECRET\" || credential.VolcengineAccessKey.Plan != \"agent_plan\" {\n\t\tt.Fatalf(\"凭证未正确持久化: %+v\", credential)\n\t}\n}\n\nfunc TestSetMiMoConsoleCookieRequiresConfirmationBeforeAdoptingKey(t *testing.T) {\n\tconst oldKey = \"tp-old-1234567890123456789012345678901234567890\"\n\tconst cookieKey = \"tp-new-1234567890123456789012345678901234567890\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != \"api-platform_serviceToken=session; userId=42\" {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase \"/api/v1/userProfile\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"userId\":\"42\"}}`))\n\t\tcase \"/api/v1/tokenPlan/detail\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"planCode\":\"max\",\"planName\":\"Max\",\"currentPeriodEnd\":\"2026-07-29 23:59:59\"}}`))\n\t\tcase \"/api/v1/tokenPlan/usage\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"monthUsage\":{\"percent\":0.25,\"items\":[{\"used\":25,\"limit\":100}]},\"usage\":{\"percent\":0.5,\"items\":[{\"used\":50,\"limit\":100}]}}}`))\n\t\tcase \"/api/v1/tokenPlan/apiKey/raw\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":\"` + cookieKey + `\"}`))\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}\n\t}))\n\tdefer console.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"credentials\":[{\"credentialUid\":\"cred_mimo\",\"apiKey\":\"` + oldKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_mimo\",\"channelUid\":\"ch_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_mimo\",\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\tdeps := &AutoManagedDeps{CfgManager: manager, MiMoConsoleClient: &MiMoConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL}}\n\trouter := setupAutoManagedRouter(deps)\n\tbody := `{\"cookie\":\"api-platform_serviceToken=session; userId=42\"}`\n\trequest := func(payload string) *httptest.ResponseRecorder {\n\t\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_mimo/credentials/cred_mimo/mimo-console-cookie\", bytes.NewBufferString(payload))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tw := httptest.NewRecorder()\n\t\trouter.ServeHTTP(w, req)\n\t\treturn w\n\t}\n\tw := request(body)\n\tif w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), `\"code\":\"mimo_cookie_key_mismatch\"`) {\n\t\tt.Fatalf(\"未要求确认: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ := manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != oldKey || credential.MiMoConsole != nil {\n\t\tt.Fatalf(\"确认前不应修改配置: %+v\", credential)\n\t}\n\tw = request(`{\"cookie\":\"api-platform_serviceToken=session; userId=42\",\"adoptCookieKey\":true}`)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"keyAdopted\":true`) {\n\t\tt.Fatalf(\"确认采用失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ = manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != cookieKey || credential.MiMoConsole == nil {\n\t\tt.Fatalf(\"确认后未采用 Cookie Key: %+v\", credential)\n\t}\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif strings.Contains(list.Body.String(), \"api-platform_serviceToken\") || strings.Contains(list.Body.String(), cookieKey) {\n\t\tt.Fatalf(\"账号列表泄漏 Cookie 或原始 Key: %s\", list.Body.String())\n\t}\n}\n\nfunc TestCompshareConsoleCookieBindingDoesNotExposeSecrets(t *testing.T) {\n\tconst currentKey = \"sk-cp-test-current\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != testCompshareCookie {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\n \"RetCode\": 0,\n \"UserPlans\": [{\n \"Code\": \"cppkg-test\", \"PlanCode\": \"cp-plan-test\", \"PlanName\": \"Pro\", \"DisplayName\": \"Pro 增强版\",\n \"LimitPer5h\": 3000, \"LimitPerWeek\": 7500, \"LimitPerMonth\": 19000,\n \"UsagePer5h\": 0, \"UsagePerWeek\": 100, \"UsagePerMonth\": 6496,\n \"ConcurrencyLimit\": 10, \"Status\": 1, \"ExpireAt\": 1785037981,\n \"Keys\": [{\"APIKey\": \"` + currentKey + `\"}]\n }]\n}`))\n\t}))\n\tdefer console.Close()\n\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_compshare\",\"providerId\":\"compshare\",\"name\":\"compshare\",\"credentials\":[{\"credentialUid\":\"cred_compshare\",\"apiKey\":\"` + currentKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_compshare\",\"channelUid\":\"ch_compshare\",\"providerId\":\"compshare\",\"name\":\"compshare\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://api.compshare.cn/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_compshare\",\"baseUrl\":\"https://api.compshare.cn/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: manager,\n\t\tCompshareConsoleClient: &CompshareConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL},\n\t})\n\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_compshare/credentials/cred_compshare/compshare-console-cookie\", bytes.NewBufferString(`{\"cookie\":\"`+testCompshareCookie+`\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"displayName\":\"Pro 增强版\"`) {\n\t\tt.Fatalf(\"绑定失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_compshare\", \"cred_compshare\")\n\tif !ok || credential.CompshareConsole == nil || credential.CompshareConsole.Cookie != testCompshareCookie {\n\t\tt.Fatalf(\"控制台套餐未持久化: %+v\", credential)\n\t}\n\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tresponse := list.Body.String()\n\tfor _, secret := range []string{\"U_JWT_TOKEN\", \"test-session\", \"test_user@console.compshare.cn\", \"org-test\", currentKey, \"cppkg-test\"} {\n\t\tif strings.Contains(response, secret) {\n\t\t\tt.Fatalf(\"账号列表泄漏敏感字段 %q: %s\", secret, response)\n\t\t}\n\t}\n\tfor _, expected := range []string{`\"hasCompshareConsoleCookie\":true`, `\"planCode\":\"cp-plan-test\"`, `\"monthlyUsage\":{\"used\":6496,\"limit\":19000`} {\n\t\tif !strings.Contains(response, expected) {\n\t\t\tt.Fatalf(\"账号列表缺少套餐字段 %s: %s\", expected, response)\n\t\t}\n\t}\n}\n\nfunc TestProviderAutoAddReusesExistingAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(`{\"providerId\":\"mimo\",\"apiKeys\":[\"sk-existing\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"追加已有 provider key status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 4 {\n\t\tt.Fatalf(\"旧账号应自动补齐模板中的协议渠道: %+v\", channels)\n\t}\n\tkinds := make(map[string]bool, len(channels))\n\tfor _, channel := range channels {\n\t\tkinds[channel.Kind] = true\n\t}\n\tfor _, kind := range []string{\"messages\", \"chat\", \"responses\", \"gemini\"} {\n\t\tif !kinds[kind] {\n\t\t\tt.Fatalf(\"旧账号缺少 %s route: %+v\", kind, channels)\n\t\t}\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.AccountUID != \"acct_test\" {\n\t\tt.Fatalf(\"响应未返回已有账号: body=%s err=%v\", w.Body.String(), err)\n\t}\n\tif len(response.Channels) != 4 {\n\t\tt.Fatalf(\"响应未返回补齐后的全部渠道: %+v\", response.Channels)\n\t}\n}\n\nfunc TestMissingProviderAccountRoutes(t *testing.T) {\n\ttmpl, ok := config.GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"缺少 deepseek provider 模板\")\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{ServiceType: \"claude\"},\n\t}}\n\tmissing := missingProviderAccountRoutes(tmpl, existing)\n\tif len(missing) != 2 || missing[0].ChannelKind != \"chat\" || missing[1].ChannelKind != \"responses\" {\n\t\tt.Fatalf(\"missing routes = %+v\", missing)\n\t}\n\tconfigs, baseURLs, err := bindProviderRouteKeys(tmpl, missing[0], []string{\"sk-existing\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(configs) != 1 || configs[0].BaseURL != \"https://api.deepseek.com\" || len(baseURLs) != 1 {\n\t\tt.Fatalf(\"route 绑定结果不正确: configs=%+v baseURLs=%v\", configs, baseURLs)\n\t}\n}\n\nfunc TestBindProviderRouteKeysPreservesExistingEndpointAffinity(t *testing.T) {\n\ttests := []struct {\n\t\tproviderID string\n\t\tapiKey string\n\t\texistingBaseURL string\n\t\ttargetKind string\n\t\twantBaseURL string\n\t}{\n\t\t{\n\t\t\tproviderID: \"mimo\", apiKey: \"tp-existing\",\n\t\t\texistingBaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\t\ttargetKind: \"chat\", wantBaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\",\n\t\t},\n\t\t{\n\t\t\tproviderID: \"volcengine\", apiKey: \"ark-existing\",\n\t\t\texistingBaseURL: \"https://ark.cn-beijing.volces.com/api/coding\",\n\t\t\ttargetKind: \"responses\", wantBaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.providerID, func(t *testing.T) {\n\t\t\ttmpl, ok := config.GetProviderTemplate(tt.providerID)\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"缺少 provider 模板: %s\", tt.providerID)\n\t\t\t}\n\t\t\texisting := []config.AccountChannel{{\n\t\t\t\tKind: \"messages\",\n\t\t\t\tUpstream: config.UpstreamConfig{\n\t\t\t\t\tProviderID: tt.providerID, ServiceType: \"claude\", APIKeys: []string{tt.apiKey},\n\t\t\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: tt.apiKey, BaseURL: tt.existingBaseURL}},\n\t\t\t\t},\n\t\t\t}}\n\t\t\tvar target config.ProviderRoute\n\t\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\t\tif route.ChannelKind == tt.targetKind {\n\t\t\t\t\ttarget = route\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tconfigs, _, err := bindProviderRouteKeysWithAffinities(\n\t\t\t\ttmpl, target, []string{tt.apiKey}, providerKeyCandidateAffinities(tmpl, existing),\n\t\t\t)\n\t\t\tif err != nil || len(configs) != 1 || configs[0].BaseURL != tt.wantBaseURL {\n\t\t\t\tt.Fatalf(\"亲和绑定错误: configs=%+v err=%v want=%s\", configs, err, tt.wantBaseURL)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPlanProviderAccountRouteAdditionsRejectsNewKeyBeforeMutation(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t}))\n\tdefer server.Close()\n\n\ttmpl := &config.ProviderTemplate{\n\t\tProviderID: \"test-provider\",\n\t\tRoutes: []config.ProviderRoute{\n\t\t\t{ChannelKind: \"messages\", ServiceType: \"claude\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/anthropic\"}}},\n\t\t\t{ChannelKind: \"chat\", ServiceType: \"openai\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/v1\"}}},\n\t\t},\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct_test\", ChannelUID: \"ch_messages\", Name: \"test-provider\",\n\t\t\tProviderID: \"test-provider\", ServiceType: \"claude\", AutoManaged: true, APIKeys: []string{\"sk-existing\"},\n\t\t},\n\t}}\n\tadditions, status, err := planProviderAccountRouteAdditions(\n\t\tt.Context(), config.Config{}, tmpl, \"acct_test\", []string{\"sk-existing\", \"sk-invalid\"}, existing,\n\t)\n\tif err == nil || status != http.StatusBadRequest || len(additions) != 0 {\n\t\tt.Fatalf(\"无效新 Key 应在生成新增 route 前失败: status=%d additions=%+v err=%v\", status, additions, err)\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}, "611ce03e3f57946db96335311fd31d67f30e65cb": {"t": 1785759345.5728855, "n": 47267, "h": "0518ff05f4ecde67", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestListAccountsMasksCredentials(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-secret-value\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil)\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"sk-secret-value\") {\n\t\tt.Fatalf(\"账号列表泄露明文 Key: %s\", body)\n\t}\n\tif !strings.Contains(body, \"cred_test\") || !strings.Contains(body, \"keyMask\") {\n\t\tt.Fatalf(\"账号列表缺少凭证掩码信息: %s\", body)\n\t}\n}\n\nfunc TestListAccountsIncludesActiveEndpointModelAvailability(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"volcengine\",\"name\":\"volcengine\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"ark-secret-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"ark-secret-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"volcengine\",\"name\":\"volcengine-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://example.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tstore, err := NewProfileStoreWithDB(newTestDB(t))\n\tif err != nil {\n\t\tt.Fatalf(\"NewProfileStoreWithDB 失败: %v\", err)\n\t}\n\tupdatedAt := time.Date(2026, 7, 17, 10, 54, 33, 0, time.UTC)\n\tfor _, profile := range []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_a\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_a\", KeyMask: \"ark-a***001\", AvailableModels: []string{\"model-b\", \"model-a\"}, UpdatedAt: updatedAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_b\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_b\", KeyMask: \"ark-b***002\", AvailableModels: []string{\"model-c\", \"model-b\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t},\n\t} {\n\t\tif err := store.Upsert(profile); err != nil {\n\t\t\tt.Fatalf(\"ProfileStore.Upsert 失败: %v\", err)\n\t\t}\n\t}\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager, Runner: runner})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response struct {\n\t\tAccounts []managedAccountView `json:\"accounts\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatalf(\"解析响应失败: %v\", err)\n\t}\n\tif len(response.Accounts) != 1 || len(response.Accounts[0].Channels) != 1 {\n\t\tt.Fatalf(\"账号渠道数量错误: %+v\", response.Accounts)\n\t}\n\tchannel := response.Accounts[0].Channels[0]\n\tif got, want := strings.Join(channel.DiscoveredModels, \",\"), \"model-a,model-b,model-c\"; got != want {\n\t\tt.Fatalf(\"DiscoveredModels=%q, want %q\", got, want)\n\t}\n\tif !channel.ModelInventoryKnown {\n\t\tt.Fatal(\"已发现模型时 ModelInventoryKnown 应为 true\")\n\t}\n\tif len(channel.ModelBindings) != 2 {\n\t\tt.Fatalf(\"ModelBindings=%+v, want 2\", channel.ModelBindings)\n\t}\n\tif got := strings.Join(channel.ModelBindings[0].Models, \",\"); got != \"model-a,model-b\" {\n\t\tt.Fatalf(\"第一个 Key 模型=%q\", got)\n\t}\n\tif channel.ModelsUpdatedAt == nil || !channel.ModelsUpdatedAt.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"ModelsUpdatedAt=%v\", channel.ModelsUpdatedAt)\n\t}\n\tif strings.Contains(w.Body.String(), \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", w.Body.String())\n\t}\n}\n\nfunc TestManagedChannelModelAvailabilityIncludesEmptyAutoDiscoveryResult(t *testing.T) {\n\tupdatedAt := time.Date(2026, 7, 17, 11, 2, 0, 0, time.UTC)\n\tmodels, bindings, latest, known := managedChannelModelAvailability([]*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_empty\", CredentialUID: \"cred_empty\", KeyMask: \"ark-e***001\",\n\t\t\tSource: \"auto_discovery\", AvailableModels: []string{}, UpdatedAt: updatedAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_models\", CredentialUID: \"cred_models\", KeyMask: \"ark-m***002\",\n\t\t\tAvailableModels: []string{\"model-a\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_unknown\", CredentialUID: \"cred_unknown\", KeyMask: \"ark-u***003\",\n\t\t\tSource: \"l1_passive\", UpdatedAt: updatedAt.Add(2 * time.Minute),\n\t\t},\n\t})\n\n\tif !known {\n\t\tt.Fatal(\"自动发现的空模型清单仍应标记为已知\")\n\t}\n\tif got, want := strings.Join(models, \",\"), \"model-a\"; got != want {\n\t\tt.Fatalf(\"models=%q, want %q\", got, want)\n\t}\n\tif len(bindings) != 2 {\n\t\tt.Fatalf(\"bindings=%+v, want only two known inventories\", bindings)\n\t}\n\tif got := bindings[0]; got.KeyMask != \"ark-e***001\" || len(got.Models) != 0 {\n\t\tt.Fatalf(\"empty discovery binding=%+v, want empty ark-e binding\", got)\n\t}\n\tif latest == nil || !latest.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"latest=%v, want discovered inventory update time\", latest)\n\t}\n}\n\nfunc TestPatchAccountCredentialsRemovesByUID(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"sk-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"sk-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"baseUrls\":[\"https://api.xiaomimimo.com/anthropic\"],\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPatch, \"/api/accounts/acct_test/credentials\", bytes.NewBufferString(`{\"removeCredentialUids\":[\"cred_b\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PATCH credentials status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 1 || len(channels[0].Upstream.APIKeys) != 1 || channels[0].Upstream.APIKeys[0] != \"sk-a\" {\n\t\tt.Fatalf(\"按 credentialUid 删除失败: %+v\", channels)\n\t}\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestInferAutoAddProviderID(t *testing.T) {\n\tzhipuKey := \"0123456789abcdef0123456789abcdef.ABCDEFGHIJKLMNO1\"\n\ttests := []struct {\n\t\tname string\n\t\tbaseURLs []string\n\t\tapiKeys []string\n\t\twant string\n\t}{\n\t\t{name: \"官方 Claude URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"官方 OpenAI URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4/\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"两个官方协议 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\", \"https://open.bigmodel.cn/api/paas/v4\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"仅智谱 Key\", apiKeys: []string{zhipuKey}, want: \"glm\"},\n\t\t{name: \"第三方 URL 不按 Key 提升\", baseURLs: []string{\"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"混合官方与第三方 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4\", \"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"共享 sk Key 无法推断\", apiKeys: []string{\"sk-abcdefghijklmnopqrstuvwxyz123456\"}},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := inferAutoAddProviderID(tt.baseURLs, tt.apiKeys); got != tt.want {\n\t\t\t\tt.Fatalf(\"inferAutoAddProviderID() = %q, want %q\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestApplyProviderUpstreamDefaults(t *testing.T) {\n\tglmChat := config.UpstreamConfig{ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmChat)\n\tif glmChat.ReasoningParamStyle != \"reasoning_effort\" || !glmChat.PassbackReasoningContent {\n\t\tt.Fatalf(\"GLM OpenAI 默认兼容参数未补齐: %+v\", glmChat)\n\t}\n\n\tglmClaude := config.UpstreamConfig{ServiceType: \"claude\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmClaude)\n\tif glmClaude.ReasoningParamStyle != \"\" || glmClaude.PassbackReasoningContent {\n\t\tt.Fatalf(\"GLM Claude 原生 route 不应注入 OpenAI 参数: %+v\", glmClaude)\n\t}\n\n\tcustom := config.UpstreamConfig{ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"\", &custom)\n\tif custom.ReasoningParamStyle != \"\" || custom.PassbackReasoningContent {\n\t\tt.Fatalf(\"自定义渠道不应应用 GLM 默认值: %+v\", custom)\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCustomAutoAddResponseIncludesActualRoute(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\tdiscoverer := NewRateLimitDiscoverer(RateLimitDiscovererConfig{QuietLogs: true})\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, RateLimitDiscoverer: discoverer})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"fastaitoken-com-test\",\"baseUrls\":[\"https://example.com\"],\"apiKeys\":[\"sk-test\"],\"rateLimitHint\":{\"initialRpm\":30,\"effectiveRpm\":15,\"rateLimited\":true,\"rateLimitedCount\":1}}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 {\n\t\tt.Fatalf(\"channels=%+v, want one actual route\", response.Channels)\n\t}\n\troute := response.Channels[0]\n\tif route.ChannelKind != \"responses\" || route.ServiceType != \"responses\" || route.ChannelUID != response.ChannelUID {\n\t\tt.Fatalf(\"route=%+v response=%+v\", route, response)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.ResponsesUpstream) != 1 || len(cfg.Upstream) != 0 {\n\t\tt.Fatalf(\"custom route persisted in wrong channel: responses=%d messages=%d\", len(cfg.ResponsesUpstream), len(cfg.Upstream))\n\t}\n\tcreated := cfg.ResponsesUpstream[0]\n\tbaseURL := created.GetEffectiveBaseURL()\n\tendpointUID := GenerateEndpointUID(created.ChannelUID, baseURL, KeyHashFromAPIKey(\"sk-test\"))\n\tif suggestion := discoverer.SuggestedLimit(endpointUID); suggestion.RPM != 15 || suggestion.Confidence != 0.7 {\n\t\tt.Fatalf(\"rate limit suggestion=%+v, want rpm=15 confidence=0.7\", suggestion)\n\t}\n\n\tstore := newTestProfileStore(t)\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\tseedAutoAddRateLimitHint(\n\t\t&AutoManagedDeps{CfgManager: manager, Runner: runner, RateLimitDiscoverer: discoverer},\n\t\tmanager.GetAccountChannels(response.AccountUID),\n\t\t&AutoAddRateLimitHint{InitialRPM: 30, EffectiveRPM: 15, RateLimited: true, RateLimitedCount: 1},\n\t\t\"sk-test\",\n\t\t\"https://example.com\",\n\t)\n\tprofile := store.Get(endpointUID)\n\tif profile == nil || profile.DiscoveredRPM != 15 || profile.RateLimitConfidence != 0.7 || profile.RateLimitSource != string(RateLimitSourcePassiveAIMD) {\n\t\tt.Fatalf(\"rate limit profile=%+v\", profile)\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyToEquivalentExistingBaseURL(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [],\n \"chatUpstream\": [{\"channelUid\":\"ch_existing\",\"name\":\"localhost-old-name\",\"serviceType\":\"openai\",\"baseUrl\":\"http://localhost:8990/v1\",\"apiKeys\":[\"sk-old\"],\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"localhost-8990\",\"baseUrls\":[\"http://localhost:8990/\"],\"apiKeys\":[\"sk-new\"]}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 || response.Channels[0].Name != \"localhost-old-name\" {\n\t\tt.Fatalf(\"应返回已有渠道: %+v\", response.Channels)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 0 || len(cfg.ChatUpstream) != 1 {\n\t\tt.Fatalf(\"重复请求不应创建新渠道: messages=%d chat=%d\", len(cfg.Upstream), len(cfg.ChatUpstream))\n\t}\n\tif got := strings.Join(cfg.ChatUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"已有渠道密钥=%q, want sk-old,sk-new\", got)\n\t}\n}\n\nfunc TestFindExistingAutoAddChannelsPreservesHashSemantics(t *testing.T) {\n\tcfg := config.Config{\n\t\tChatUpstream: []config.UpstreamConfig{{\n\t\t\tChannelUID: \"ch-hash\", ServiceType: \"openai\", BaseURL: \"https://example.com#\",\n\t\t}},\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com\"}); len(matches) != 0 {\n\t\tt.Fatalf(\"普通 Base URL 不应匹配禁止版本前缀的地址: %+v\", matches)\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com/#\"}); len(matches) != 1 {\n\t\tt.Fatalf(\"等效 # 地址匹配数量=%d, want 1\", len(matches))\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyAndMissingRouteToManagedAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [{\"accountUid\":\"acct_existing\",\"channelUid\":\"ch_existing\",\"name\":\"localhost-8990\",\"serviceType\":\"claude\",\"baseUrl\":\"http://localhost:8990\",\"apiKeys\":[\"sk-old\"],\"autoManaged\":true,\"status\":\"active\"}],\n \"chatUpstream\": [], \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"localhost-8990\",\n \"baseUrls\":[\"http://localhost:8990/v1\"],\n \"apiKeys\":[\"sk-new\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"claude-test\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"gpt-test\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d\", len(cfg.Upstream), len(cfg.ResponsesUpstream))\n\t}\n\tif cfg.Upstream[0].AccountUID != \"acct_existing\" || cfg.ResponsesUpstream[0].AccountUID != \"acct_existing\" {\n\t\tt.Fatalf(\"应复用已有账号: messages=%q responses=%q\", cfg.Upstream[0].AccountUID, cfg.ResponsesUpstream[0].AccountUID)\n\t}\n\tif got := strings.Join(cfg.Upstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"messages 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif got := strings.Join(cfg.ResponsesUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"responses 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif cfg.Upstream[0].Name != \"localhost-8990-claude\" || cfg.ResponsesUpstream[0].Name != \"localhost-8990-codex\" {\n\t\tt.Fatalf(\"多协议自动命名错误: messages=%q responses=%q\", cfg.Upstream[0].Name, cfg.ResponsesUpstream[0].Name)\n\t}\n}\n\nfunc TestCustomAutoAddCreatesAllDetectedRoutesWithProtocolModels(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"fastaitoken-com-test\",\n \"baseUrls\":[\"https://example.com/keys\"],\n \"apiKeys\":[\"sk-test\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"shared\",\"messages-only\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"shared\",\"responses-only\"]},\n {\"channelKind\":\"chat\",\"supportedModels\":[\"shared\",\"chat-only\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 3 {\n\t\tt.Fatalf(\"channels=%+v, want three detected routes\", response.Channels)\n\t}\n\tfor _, route := range response.Channels {\n\t\tif route.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route account=%q, want %q\", route.AccountUID, response.AccountUID)\n\t\t}\n\t}\n\tif response.Channels[1].ChannelKind != \"responses\" || response.ChannelUID != response.Channels[1].ChannelUID {\n\t\tt.Fatalf(\"请求协议应作为主路由: response=%+v\", response)\n\t}\n\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 || len(cfg.ChatUpstream) != 1 || len(cfg.GeminiUpstream) != 0 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d chat=%d gemini=%d\",\n\t\t\tlen(cfg.Upstream), len(cfg.ResponsesUpstream), len(cfg.ChatUpstream), len(cfg.GeminiUpstream))\n\t}\n\tchecks := []struct {\n\t\tchannel config.UpstreamConfig\n\t\tname string\n\t\tmodels []string\n\t}{\n\t\t{cfg.Upstream[0], \"fastaitoken-com-test-claude\", []string{\"shared\", \"messages-only\"}},\n\t\t{cfg.ResponsesUpstream[0], \"fastaitoken-com-test-codex\", []string{\"shared\", \"responses-only\"}},\n\t\t{cfg.ChatUpstream[0], \"fastaitoken-com-test-chat\", []string{\"shared\", \"chat-only\"}},\n\t}\n\tfor _, check := range checks {\n\t\tif check.channel.Name != check.name || check.channel.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route identity=%+v, want name=%q account=%q\", check.channel, check.name, response.AccountUID)\n\t\t}\n\t\tif strings.Join(check.channel.SupportedModels, \",\") != strings.Join(check.models, \",\") {\n\t\t\tt.Fatalf(\"route %s models=%v, want %v\", check.name, check.channel.SupportedModels, check.models)\n\t\t}\n\t}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesRenamesAndSyncsCredentials(t *testing.T) {\n\tchannels := []config.AccountChannel{\n\t\t{Kind: \"messages\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-messages\", Name: \"old-claude\",\n\t\t\tServiceType: \"claude\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t\t{Kind: \"responses\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-responses\", Name: \"old-codex\",\n\t\t\tServiceType: \"responses\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t}\n\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\tName: \"renamed\", APIKeys: []string{\"sk-old\", \"sk-new\"},\n\t}, channels, len(channels))\n\tif err != nil || status != http.StatusOK {\n\t\tt.Fatalf(\"status=%d err=%v\", status, err)\n\t}\n\tif len(updates) != 2 || updates[0].Name != \"renamed-claude\" || updates[1].Name != \"renamed-codex\" {\n\t\tt.Fatalf(\"route names=%+v\", updates)\n\t}\n\tfor _, update := range updates {\n\t\tif len(update.APIKeyConfig) != 2 || update.APIKeyConfig[1].BaseURL != \"https://example.com\" {\n\t\t\tt.Fatalf(\"route credentials=%+v\", update.APIKeyConfig)\n\t\t}\n\t\tif update.APIKeyConfig[1].CredentialUID == \"\" {\n\t\t\tt.Fatalf(\"new credential missing stable uid: %+v\", update.APIKeyConfig[1])\n\t\t}\n\t}\n}\n\nfunc TestProviderRouteNameAndPrimaryResult(t *testing.T) {\n\tbase := \"mimo-test\"\n\ttests := []struct {\n\t\troute config.ProviderRoute\n\t\twant string\n\t}{\n\t\t{route: config.ProviderRoute{ChannelKind: \"messages\"}, want: \"mimo-test-claude\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"chat\"}, want: \"mimo-test-chat\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"responses\"}, want: \"mimo-test-codex\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"gemini\"}, want: \"mimo-test-gemini\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := providerRouteName(base, tt.route, true); got != tt.want {\n\t\t\tt.Fatalf(\"providerRouteName(%s)=%q, want %q\", tt.route.ChannelKind, got, tt.want)\n\t\t}\n\t}\n\tif got := providerRouteName(base, config.ProviderRoute{ChannelKind: \"messages\"}, false); got != base {\n\t\tt.Fatalf(\"single route name=%q, want %q\", got, base)\n\t}\n\n\tresults := []AutoAddChannelResult{\n\t\t{ChannelKind: \"messages\", ChannelUID: \"ch_messages\", Index: 1},\n\t\t{ChannelKind: \"chat\", ChannelUID: \"ch_chat\", Index: 2},\n\t\t{ChannelKind: \"responses\", ChannelUID: \"ch_responses\", Index: 3},\n\t}\n\tprimary := primaryAutoAddResult(results, \"chat\")\n\tif primary.ChannelUID != \"ch_chat\" || primary.Index != 2 {\n\t\tt.Fatalf(\"primary=%+v, want chat result\", primary)\n\t}\n}\n\nfunc TestManagedAccountUIDForProviderUsesExistingAccount(t *testing.T) {\n\tcfg := config.Config{ManagedAccounts: []config.ManagedAccountConfig{\n\t\t{AccountUID: \"acct-old\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-new\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-deepseek\", ProviderID: \"deepseek\"},\n\t}}\n\tif got := managedAccountUIDForProvider(cfg, \"mimo\"); got != \"acct-new\" {\n\t\tt.Fatalf(\"mimo existing account = %q, want acct-new\", got)\n\t}\n\tif got := managedAccountUIDForProvider(cfg, \"unknown\"); got != \"\" {\n\t\tt.Fatalf(\"unknown provider 不应命中账号: %q\", got)\n\t}\n}\n\nfunc TestListManagedAccountsDoesNotExposeVolcengineSecret(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-secret\",\"volcengineAccessKey\":{\"accessKeyId\":\"AKIDEXAMPLE\",\"secretAccessKey\":\"SECRET_MUST_NOT_LEAK\",\"plan\":\"agent_plan\",\"planTier\":\"Large\",\"planStatus\":\"Running\"}}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"SECRET_MUST_NOT_LEAK\") || strings.Contains(body, \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", body)\n\t}\n\tfor _, expected := range []string{`\"hasVolcengineAccessKey\":true`, `\"volcenginePlan\":\"agent_plan\"`, `\"volcenginePlanTier\":\"Large\"`} {\n\t\tif !strings.Contains(body, expected) {\n\t\t\tt.Fatalf(\"响应缺少 %s: %s\", expected, body)\n\t\t}\n\t}\n}\n\nfunc TestSetVolcengineAccessKeyDetectsPlanBeforePersisting(t *testing.T) {\n\tcontrolPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Query().Get(\"Action\") != \"GetPersonalPlan\" {\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tvar body struct{ Plan string }\n\t\t_ = json.NewDecoder(r.Body).Decode(&body)\n\t\tif body.Plan == \"CodingPlan\" {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t_, _ = w.Write([]byte(`{\"ResponseMetadata\":{\"Error\":{\"Code\":\"ResourceNotFound.Plan\",\"Message\":\"not found\"}}}`))\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\"Result\":{\"PlanType\":\"Large\",\"Status\":\"Running\"}}`))\n\t}))\n\tdefer controlPlane.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-inference\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\trunner := NewAutoDiscoveryRunner(nil, nil)\n\trunner.client = controlPlane.Client()\n\trunner.volcengineControlPlaneEndpoint = controlPlane.URL\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, Runner: runner})\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_volc/credentials/cred_volc/volcengine-access-key\", bytes.NewBufferString(`{\"accessKeyId\":\"AKID\",\"secretAccessKey\":\"SECRET\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"plan\":\"agent_plan\"`) {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_volc\", \"cred_volc\")\n\tif !ok || credential.VolcengineAccessKey == nil || credential.VolcengineAccessKey.SecretAccessKey != \"SECRET\" || credential.VolcengineAccessKey.Plan != \"agent_plan\" {\n\t\tt.Fatalf(\"凭证未正确持久化: %+v\", credential)\n\t}\n}\n\nfunc TestSetMiMoConsoleCookieRequiresConfirmationBeforeAdoptingKey(t *testing.T) {\n\tconst oldKey = \"tp-old-1234567890123456789012345678901234567890\"\n\tconst cookieKey = \"tp-new-1234567890123456789012345678901234567890\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != \"api-platform_serviceToken=session; userId=42\" {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase \"/api/v1/userProfile\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"userId\":\"42\"}}`))\n\t\tcase \"/api/v1/tokenPlan/detail\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"planCode\":\"max\",\"planName\":\"Max\",\"currentPeriodEnd\":\"2026-07-29 23:59:59\"}}`))\n\t\tcase \"/api/v1/tokenPlan/usage\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"monthUsage\":{\"percent\":0.25,\"items\":[{\"used\":25,\"limit\":100}]},\"usage\":{\"percent\":0.5,\"items\":[{\"used\":50,\"limit\":100}]}}}`))\n\t\tcase \"/api/v1/tokenPlan/apiKey/raw\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":\"` + cookieKey + `\"}`))\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}\n\t}))\n\tdefer console.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"credentials\":[{\"credentialUid\":\"cred_mimo\",\"apiKey\":\"` + oldKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_mimo\",\"channelUid\":\"ch_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_mimo\",\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\tdeps := &AutoManagedDeps{CfgManager: manager, MiMoConsoleClient: &MiMoConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL}}\n\trouter := setupAutoManagedRouter(deps)\n\tbody := `{\"cookie\":\"api-platform_serviceToken=session; userId=42\"}`\n\trequest := func(payload string) *httptest.ResponseRecorder {\n\t\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_mimo/credentials/cred_mimo/mimo-console-cookie\", bytes.NewBufferString(payload))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tw := httptest.NewRecorder()\n\t\trouter.ServeHTTP(w, req)\n\t\treturn w\n\t}\n\tw := request(body)\n\tif w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), `\"code\":\"mimo_cookie_key_mismatch\"`) {\n\t\tt.Fatalf(\"未要求确认: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ := manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != oldKey || credential.MiMoConsole != nil {\n\t\tt.Fatalf(\"确认前不应修改配置: %+v\", credential)\n\t}\n\tw = request(`{\"cookie\":\"api-platform_serviceToken=session; userId=42\",\"adoptCookieKey\":true}`)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"keyAdopted\":true`) {\n\t\tt.Fatalf(\"确认采用失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ = manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != cookieKey || credential.MiMoConsole == nil {\n\t\tt.Fatalf(\"确认后未采用 Cookie Key: %+v\", credential)\n\t}\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif strings.Contains(list.Body.String(), \"api-platform_serviceToken\") || strings.Contains(list.Body.String(), cookieKey) {\n\t\tt.Fatalf(\"账号列表泄漏 Cookie 或原始 Key: %s\", list.Body.String())\n\t}\n}\n\nfunc TestCompshareConsoleCookieBindingDoesNotExposeSecrets(t *testing.T) {\n\tconst currentKey = \"sk-cp-test-current\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != testCompshareCookie {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\n \"RetCode\": 0,\n \"UserPlans\": [{\n \"Code\": \"cppkg-test\", \"PlanCode\": \"cp-plan-test\", \"PlanName\": \"Pro\", \"DisplayName\": \"Pro 增强版\",\n \"LimitPer5h\": 3000, \"LimitPerWeek\": 7500, \"LimitPerMonth\": 19000,\n \"UsagePer5h\": 0, \"UsagePerWeek\": 100, \"UsagePerMonth\": 6496,\n \"ConcurrencyLimit\": 10, \"Status\": 1, \"ExpireAt\": 1785037981,\n \"Keys\": [{\"APIKey\": \"` + currentKey + `\"}]\n }]\n}`))\n\t}))\n\tdefer console.Close()\n\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_compshare\",\"providerId\":\"compshare\",\"name\":\"compshare\",\"credentials\":[{\"credentialUid\":\"cred_compshare\",\"apiKey\":\"` + currentKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_compshare\",\"channelUid\":\"ch_compshare\",\"providerId\":\"compshare\",\"name\":\"compshare\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://api.compshare.cn/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_compshare\",\"baseUrl\":\"https://api.compshare.cn/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: manager,\n\t\tCompshareConsoleClient: &CompshareConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL},\n\t})\n\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_compshare/credentials/cred_compshare/compshare-console-cookie\", bytes.NewBufferString(`{\"cookie\":\"`+testCompshareCookie+`\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"displayName\":\"Pro 增强版\"`) {\n\t\tt.Fatalf(\"绑定失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_compshare\", \"cred_compshare\")\n\tif !ok || credential.CompshareConsole == nil || credential.CompshareConsole.Cookie != testCompshareCookie {\n\t\tt.Fatalf(\"控制台套餐未持久化: %+v\", credential)\n\t}\n\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tresponse := list.Body.String()\n\tfor _, secret := range []string{\"U_JWT_TOKEN\", \"test-session\", \"test_user@console.compshare.cn\", \"org-test\", currentKey, \"cppkg-test\"} {\n\t\tif strings.Contains(response, secret) {\n\t\t\tt.Fatalf(\"账号列表泄漏敏感字段 %q: %s\", secret, response)\n\t\t}\n\t}\n\tfor _, expected := range []string{`\"hasCompshareConsoleCookie\":true`, `\"planCode\":\"cp-plan-test\"`, `\"monthlyUsage\":{\"used\":6496,\"limit\":19000`} {\n\t\tif !strings.Contains(response, expected) {\n\t\t\tt.Fatalf(\"账号列表缺少套餐字段 %s: %s\", expected, response)\n\t\t}\n\t}\n}\n\nfunc TestProviderAutoAddReusesExistingAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(`{\"providerId\":\"mimo\",\"apiKeys\":[\"sk-existing\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"追加已有 provider key status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 4 {\n\t\tt.Fatalf(\"旧账号应自动补齐模板中的协议渠道: %+v\", channels)\n\t}\n\tkinds := make(map[string]bool, len(channels))\n\tfor _, channel := range channels {\n\t\tkinds[channel.Kind] = true\n\t}\n\tfor _, kind := range []string{\"messages\", \"chat\", \"responses\", \"gemini\"} {\n\t\tif !kinds[kind] {\n\t\t\tt.Fatalf(\"旧账号缺少 %s route: %+v\", kind, channels)\n\t\t}\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.AccountUID != \"acct_test\" {\n\t\tt.Fatalf(\"响应未返回已有账号: body=%s err=%v\", w.Body.String(), err)\n\t}\n\tif len(response.Channels) != 4 {\n\t\tt.Fatalf(\"响应未返回补齐后的全部渠道: %+v\", response.Channels)\n\t}\n}\n\nfunc TestMissingProviderAccountRoutes(t *testing.T) {\n\ttmpl, ok := config.GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"缺少 deepseek provider 模板\")\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{ServiceType: \"claude\"},\n\t}}\n\tmissing := missingProviderAccountRoutes(tmpl, existing)\n\tif len(missing) != 2 || missing[0].ChannelKind != \"chat\" || missing[1].ChannelKind != \"responses\" {\n\t\tt.Fatalf(\"missing routes = %+v\", missing)\n\t}\n\tconfigs, baseURLs, err := bindProviderRouteKeys(tmpl, missing[0], []string{\"sk-existing\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(configs) != 1 || configs[0].BaseURL != \"https://api.deepseek.com\" || len(baseURLs) != 1 {\n\t\tt.Fatalf(\"route 绑定结果不正确: configs=%+v baseURLs=%v\", configs, baseURLs)\n\t}\n}\n\nfunc TestBindProviderRouteKeysPreservesExistingEndpointAffinity(t *testing.T) {\n\ttests := []struct {\n\t\tproviderID string\n\t\tapiKey string\n\t\texistingBaseURL string\n\t\ttargetKind string\n\t\twantBaseURL string\n\t}{\n\t\t{\n\t\t\tproviderID: \"mimo\", apiKey: \"tp-existing\",\n\t\t\texistingBaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\t\ttargetKind: \"chat\", wantBaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\",\n\t\t},\n\t\t{\n\t\t\tproviderID: \"volcengine\", apiKey: \"ark-existing\",\n\t\t\texistingBaseURL: \"https://ark.cn-beijing.volces.com/api/coding\",\n\t\t\ttargetKind: \"responses\", wantBaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.providerID, func(t *testing.T) {\n\t\t\ttmpl, ok := config.GetProviderTemplate(tt.providerID)\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"缺少 provider 模板: %s\", tt.providerID)\n\t\t\t}\n\t\t\texisting := []config.AccountChannel{{\n\t\t\t\tKind: \"messages\",\n\t\t\t\tUpstream: config.UpstreamConfig{\n\t\t\t\t\tProviderID: tt.providerID, ServiceType: \"claude\", APIKeys: []string{tt.apiKey},\n\t\t\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: tt.apiKey, BaseURL: tt.existingBaseURL}},\n\t\t\t\t},\n\t\t\t}}\n\t\t\tvar target config.ProviderRoute\n\t\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\t\tif route.ChannelKind == tt.targetKind {\n\t\t\t\t\ttarget = route\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tconfigs, _, err := bindProviderRouteKeysWithAffinities(\n\t\t\t\ttmpl, target, []string{tt.apiKey}, providerKeyCandidateAffinities(tmpl, existing),\n\t\t\t)\n\t\t\tif err != nil || len(configs) != 1 || configs[0].BaseURL != tt.wantBaseURL {\n\t\t\t\tt.Fatalf(\"亲和绑定错误: configs=%+v err=%v want=%s\", configs, err, tt.wantBaseURL)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPlanProviderAccountRouteAdditionsRejectsNewKeyBeforeMutation(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t}))\n\tdefer server.Close()\n\n\ttmpl := &config.ProviderTemplate{\n\t\tProviderID: \"test-provider\",\n\t\tRoutes: []config.ProviderRoute{\n\t\t\t{ChannelKind: \"messages\", ServiceType: \"claude\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/anthropic\"}}},\n\t\t\t{ChannelKind: \"chat\", ServiceType: \"openai\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/v1\"}}},\n\t\t},\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct_test\", ChannelUID: \"ch_messages\", Name: \"test-provider\",\n\t\t\tProviderID: \"test-provider\", ServiceType: \"claude\", AutoManaged: true, APIKeys: []string{\"sk-existing\"},\n\t\t},\n\t}}\n\tadditions, status, err := planProviderAccountRouteAdditions(\n\t\tt.Context(), config.Config{}, tmpl, \"acct_test\", []string{\"sk-existing\", \"sk-invalid\"}, existing,\n\t)\n\tif err == nil || status != http.StatusBadRequest || len(additions) != 0 {\n\t\tt.Fatalf(\"无效新 Key 应在生成新增 route 前失败: status=%d additions=%+v err=%v\", status, additions, err)\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}, "c48015b8e2d5a892e694abfe405666e082f03231": {"t": 1785759348.208724, "n": 44055, "h": "863c16ad85075605", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestListAccountsMasksCredentials(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-secret-value\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil)\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"sk-secret-value\") {\n\t\tt.Fatalf(\"账号列表泄露明文 Key: %s\", body)\n\t}\n\tif !strings.Contains(body, \"cred_test\") || !strings.Contains(body, \"keyMask\") {\n\t\tt.Fatalf(\"账号列表缺少凭证掩码信息: %s\", body)\n\t}\n}\n\nfunc TestListAccountsIncludesActiveEndpointModelAvailability(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"volcengine\",\"name\":\"volcengine\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"ark-secret-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"ark-secret-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"volcengine\",\"name\":\"volcengine-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://example.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tstore, err := NewProfileStoreWithDB(newTestDB(t))\n\tif err != nil {\n\t\tt.Fatalf(\"NewProfileStoreWithDB 失败: %v\", err)\n\t}\n\tupdatedAt := time.Date(2026, 7, 17, 10, 54, 33, 0, time.UTC)\n\tfor _, profile := range []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_a\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_a\", KeyMask: \"ark-a***001\", AvailableModels: []string{\"model-b\", \"model-a\"}, UpdatedAt: updatedAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_b\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_b\", KeyMask: \"ark-b***002\", AvailableModels: []string{\"model-c\", \"model-b\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t},\n\t} {\n\t\tif err := store.Upsert(profile); err != nil {\n\t\t\tt.Fatalf(\"ProfileStore.Upsert 失败: %v\", err)\n\t\t}\n\t}\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager, Runner: runner})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response struct {\n\t\tAccounts []managedAccountView `json:\"accounts\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatalf(\"解析响应失败: %v\", err)\n\t}\n\tif len(response.Accounts) != 1 || len(response.Accounts[0].Channels) != 1 {\n\t\tt.Fatalf(\"账号渠道数量错误: %+v\", response.Accounts)\n\t}\n\tchannel := response.Accounts[0].Channels[0]\n\tif got, want := strings.Join(channel.DiscoveredModels, \",\"), \"model-a,model-b,model-c\"; got != want {\n\t\tt.Fatalf(\"DiscoveredModels=%q, want %q\", got, want)\n\t}\n\tif !channel.ModelInventoryKnown {\n\t\tt.Fatal(\"已发现模型时 ModelInventoryKnown 应为 true\")\n\t}\n\tif len(channel.ModelBindings) != 2 {\n\t\tt.Fatalf(\"ModelBindings=%+v, want 2\", channel.ModelBindings)\n\t}\n\tif got := strings.Join(channel.ModelBindings[0].Models, \",\"); got != \"model-a,model-b\" {\n\t\tt.Fatalf(\"第一个 Key 模型=%q\", got)\n\t}\n\tif channel.ModelsUpdatedAt == nil || !channel.ModelsUpdatedAt.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"ModelsUpdatedAt=%v\", channel.ModelsUpdatedAt)\n\t}\n\tif strings.Contains(w.Body.String(), \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", w.Body.String())\n\t}\n}\n\nfunc TestManagedChannelModelAvailabilityIncludesEmptyAutoDiscoveryResult(t *testing.T) {\n\tupdatedAt := time.Date(2026, 7, 17, 11, 2, 0, 0, time.UTC)\n\tmodels, bindings, latest, known := managedChannelModelAvailability([]*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_empty\", CredentialUID: \"cred_empty\", KeyMask: \"ark-e***001\",\n\t\t\tSource: \"auto_discovery\", AvailableModels: []string{}, UpdatedAt: updatedAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_models\", CredentialUID: \"cred_models\", KeyMask: \"ark-m***002\",\n\t\t\tAvailableModels: []string{\"model-a\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_unknown\", CredentialUID: \"cred_unknown\", KeyMask: \"ark-u***003\",\n\t\t\tSource: \"l1_passive\", UpdatedAt: updatedAt.Add(2 * time.Minute),\n\t\t},\n\t})\n\n\tif !known {\n\t\tt.Fatal(\"自动发现的空模型清单仍应标记为已知\")\n\t}\n\tif got, want := strings.Join(models, \",\"), \"model-a\"; got != want {\n\t\tt.Fatalf(\"models=%q, want %q\", got, want)\n\t}\n\tif len(bindings) != 2 {\n\t\tt.Fatalf(\"bindings=%+v, want only two known inventories\", bindings)\n\t}\n\tif got := bindings[0]; got.KeyMask != \"ark-e***001\" || len(got.Models) != 0 {\n\t\tt.Fatalf(\"empty discovery binding=%+v, want empty ark-e binding\", got)\n\t}\n\tif latest == nil || !latest.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"latest=%v, want discovered inventory update time\", latest)\n\t}\n}\n\nfunc TestPatchAccountCredentialsRemovesByUID(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"sk-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"sk-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"baseUrls\":[\"https://api.xiaomimimo.com/anthropic\"],\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPatch, \"/api/accounts/acct_test/credentials\", bytes.NewBufferString(`{\"removeCredentialUids\":[\"cred_b\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PATCH credentials status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 1 || len(channels[0].Upstream.APIKeys) != 1 || channels[0].Upstream.APIKeys[0] != \"sk-a\" {\n\t\tt.Fatalf(\"按 credentialUid 删除失败: %+v\", channels)\n\t}\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestInferAutoAddProviderID(t *testing.T) {\n\tzhipuKey := \"0123456789abcdef0123456789abcdef.ABCDEFGHIJKLMNO1\"\n\ttests := []struct {\n\t\tname string\n\t\tbaseURLs []string\n\t\tapiKeys []string\n\t\twant string\n\t}{\n\t\t{name: \"官方 Claude URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"官方 OpenAI URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4/\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"两个官方协议 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\", \"https://open.bigmodel.cn/api/paas/v4\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"仅智谱 Key\", apiKeys: []string{zhipuKey}, want: \"glm\"},\n\t\t{name: \"第三方 URL 不按 Key 提升\", baseURLs: []string{\"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"混合官方与第三方 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4\", \"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"共享 sk Key 无法推断\", apiKeys: []string{\"sk-abcdefghijklmnopqrstuvwxyz123456\"}},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := inferAutoAddProviderID(tt.baseURLs, tt.apiKeys); got != tt.want {\n\t\t\t\tt.Fatalf(\"inferAutoAddProviderID() = %q, want %q\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestApplyProviderUpstreamDefaults(t *testing.T) {\n\tglmChat := config.UpstreamConfig{ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmChat)\n\tif glmChat.ReasoningParamStyle != \"reasoning_effort\" || !glmChat.PassbackReasoningContent {\n\t\tt.Fatalf(\"GLM OpenAI 默认兼容参数未补齐: %+v\", glmChat)\n\t}\n\n\tglmClaude := config.UpstreamConfig{ServiceType: \"claude\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmClaude)\n\tif glmClaude.ReasoningParamStyle != \"\" || glmClaude.PassbackReasoningContent {\n\t\tt.Fatalf(\"GLM Claude 原生 route 不应注入 OpenAI 参数: %+v\", glmClaude)\n\t}\n\n\tcustom := config.UpstreamConfig{ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"\", &custom)\n\tif custom.ReasoningParamStyle != \"\" || custom.PassbackReasoningContent {\n\t\tt.Fatalf(\"自定义渠道不应应用 GLM 默认值: %+v\", custom)\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCustomAutoAddResponseIncludesActualRoute(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\tdiscoverer := NewRateLimitDiscoverer(RateLimitDiscovererConfig{QuietLogs: true})\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, RateLimitDiscoverer: discoverer})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"fastaitoken-com-test\",\"baseUrls\":[\"https://example.com\"],\"apiKeys\":[\"sk-test\"],\"rateLimitHint\":{\"initialRpm\":30,\"effectiveRpm\":15,\"rateLimited\":true,\"rateLimitedCount\":1}}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 {\n\t\tt.Fatalf(\"channels=%+v, want one actual route\", response.Channels)\n\t}\n\troute := response.Channels[0]\n\tif route.ChannelKind != \"responses\" || route.ServiceType != \"responses\" || route.ChannelUID != response.ChannelUID {\n\t\tt.Fatalf(\"route=%+v response=%+v\", route, response)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.ResponsesUpstream) != 1 || len(cfg.Upstream) != 0 {\n\t\tt.Fatalf(\"custom route persisted in wrong channel: responses=%d messages=%d\", len(cfg.ResponsesUpstream), len(cfg.Upstream))\n\t}\n\tcreated := cfg.ResponsesUpstream[0]\n\tbaseURL := created.GetEffectiveBaseURL()\n\tendpointUID := GenerateEndpointUID(created.ChannelUID, baseURL, KeyHashFromAPIKey(\"sk-test\"))\n\tif suggestion := discoverer.SuggestedLimit(endpointUID); suggestion.RPM != 15 || suggestion.Confidence != 0.7 {\n\t\tt.Fatalf(\"rate limit suggestion=%+v, want rpm=15 confidence=0.7\", suggestion)\n\t}\n\n\tstore := newTestProfileStore(t)\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\tseedAutoAddRateLimitHint(\n\t\t&AutoManagedDeps{CfgManager: manager, Runner: runner, RateLimitDiscoverer: discoverer},\n\t\tmanager.GetAccountChannels(response.AccountUID),\n\t\t&AutoAddRateLimitHint{InitialRPM: 30, EffectiveRPM: 15, RateLimited: true, RateLimitedCount: 1},\n\t\t\"sk-test\",\n\t\t\"https://example.com\",\n\t)\n\tprofile := store.Get(endpointUID)\n\tif profile == nil || profile.DiscoveredRPM != 15 || profile.RateLimitConfidence != 0.7 || profile.RateLimitSource != string(RateLimitSourcePassiveAIMD) {\n\t\tt.Fatalf(\"rate limit profile=%+v\", profile)\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyToEquivalentExistingBaseURL(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [],\n \"chatUpstream\": [{\"channelUid\":\"ch_existing\",\"name\":\"localhost-old-name\",\"serviceType\":\"openai\",\"baseUrl\":\"http://localhost:8990/v1\",\"apiKeys\":[\"sk-old\"],\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"localhost-8990\",\"baseUrls\":[\"http://localhost:8990/\"],\"apiKeys\":[\"sk-new\"]}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 || response.Channels[0].Name != \"localhost-old-name\" {\n\t\tt.Fatalf(\"应返回已有渠道: %+v\", response.Channels)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 0 || len(cfg.ChatUpstream) != 1 {\n\t\tt.Fatalf(\"重复请求不应创建新渠道: messages=%d chat=%d\", len(cfg.Upstream), len(cfg.ChatUpstream))\n\t}\n\tif got := strings.Join(cfg.ChatUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"已有渠道密钥=%q, want sk-old,sk-new\", got)\n\t}\n}\n\nfunc TestFindExistingAutoAddChannelsPreservesHashSemantics(t *testing.T) {\n\tcfg := config.Config{\n\t\tChatUpstream: []config.UpstreamConfig{{\n\t\t\tChannelUID: \"ch-hash\", ServiceType: \"openai\", BaseURL: \"https://example.com#\",\n\t\t}},\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com\"}); len(matches) != 0 {\n\t\tt.Fatalf(\"普通 Base URL 不应匹配禁止版本前缀的地址: %+v\", matches)\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com/#\"}); len(matches) != 1 {\n\t\tt.Fatalf(\"等效 # 地址匹配数量=%d, want 1\", len(matches))\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyAndMissingRouteToManagedAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [{\"accountUid\":\"acct_existing\",\"channelUid\":\"ch_existing\",\"name\":\"localhost-8990\",\"serviceType\":\"claude\",\"baseUrl\":\"http://localhost:8990\",\"apiKeys\":[\"sk-old\"],\"autoManaged\":true,\"status\":\"active\"}],\n \"chatUpstream\": [], \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"localhost-8990\",\n \"baseUrls\":[\"http://localhost:8990/v1\"],\n \"apiKeys\":[\"sk-new\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"claude-test\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"gpt-test\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d\", len(cfg.Upstream), len(cfg.ResponsesUpstream))\n\t}\n\tif cfg.Upstream[0].AccountUID != \"acct_existing\" || cfg.ResponsesUpstream[0].AccountUID != \"acct_existing\" {\n\t\tt.Fatalf(\"应复用已有账号: messages=%q responses=%q\", cfg.Upstream[0].AccountUID, cfg.ResponsesUpstream[0].AccountUID)\n\t}\n\tif got := strings.Join(cfg.Upstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"messages 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif got := strings.Join(cfg.ResponsesUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"responses 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif cfg.Upstream[0].Name != \"localhost-8990-claude\" || cfg.ResponsesUpstream[0].Name != \"localhost-8990-codex\" {\n\t\tt.Fatalf(\"多协议自动命名错误: messages=%q responses=%q\", cfg.Upstream[0].Name, cfg.ResponsesUpstream[0].Name)\n\t}\n}\n\nfunc TestCustomAutoAddCreatesAllDetectedRoutesWithProtocolModels(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"fastaitoken-com-test\",\n \"baseUrls\":[\"https://example.com/keys\"],\n \"apiKeys\":[\"sk-test\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"shared\",\"messages-only\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"shared\",\"responses-only\"]},\n {\"channelKind\":\"chat\",\"supportedModels\":[\"shared\",\"chat-only\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 3 {\n\t\tt.Fatalf(\"channels=%+v, want three detected routes\", response.Channels)\n\t}\n\tfor _, route := range response.Channels {\n\t\tif route.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route account=%q, want %q\", route.AccountUID, response.AccountUID)\n\t\t}\n\t}\n\tif response.Channels[1].ChannelKind != \"responses\" || response.ChannelUID != response.Channels[1].ChannelUID {\n\t\tt.Fatalf(\"请求协议应作为主路由: response=%+v\", response)\n\t}\n\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 || len(cfg.ChatUpstream) != 1 || len(cfg.GeminiUpstream) != 0 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d chat=%d gemini=%d\",\n\t\t\tlen(cfg.Upstream), len(cfg.ResponsesUpstream), len(cfg.ChatUpstream), len(cfg.GeminiUpstream))\n\t}\n\tchecks := []struct {\n\t\tchannel config.UpstreamConfig\n\t\tname string\n\t\tmodels []string\n\t}{\n\t\t{cfg.Upstream[0], \"fastaitoken-com-test-claude\", []string{\"shared\", \"messages-only\"}},\n\t\t{cfg.ResponsesUpstream[0], \"fastaitoken-com-test-codex\", []string{\"shared\", \"responses-only\"}},\n\t\t{cfg.ChatUpstream[0], \"fastaitoken-com-test-chat\", []string{\"shared\", \"chat-only\"}},\n\t}\n\tfor _, check := range checks {\n\t\tif check.channel.Name != check.name || check.channel.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route identity=%+v, want name=%q account=%q\", check.channel, check.name, response.AccountUID)\n\t\t}\n\t\tif strings.Join(check.channel.SupportedModels, \",\") != strings.Join(check.models, \",\") {\n\t\t\tt.Fatalf(\"route %s models=%v, want %v\", check.name, check.channel.SupportedModels, check.models)\n\t\t}\n\t}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesRenamesAndSyncsCredentials(t *testing.T) {\n\tchannels := []config.AccountChannel{\n\t\t{Kind: \"messages\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-messages\", Name: \"old-claude\",\n\t\t\tServiceType: \"claude\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t\t{Kind: \"responses\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-responses\", Name: \"old-codex\",\n\t\t\tServiceType: \"responses\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t}\n\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\tName: \"renamed\", APIKeys: []string{\"sk-old\", \"sk-new\"},\n\t}, channels, len(channels))\n\tif err != nil || status != http.StatusOK {\n\t\tt.Fatalf(\"status=%d err=%v\", status, err)\n\t}\n\tif len(updates) != 2 || updates[0].Name != \"renamed-claude\" || updates[1].Name != \"renamed-codex\" {\n\t\tt.Fatalf(\"route names=%+v\", updates)\n\t}\n\tfor _, update := range updates {\n\t\tif len(update.APIKeyConfig) != 2 || update.APIKeyConfig[1].BaseURL != \"https://example.com\" {\n\t\t\tt.Fatalf(\"route credentials=%+v\", update.APIKeyConfig)\n\t\t}\n\t\tif update.APIKeyConfig[1].CredentialUID == \"\" {\n\t\t\tt.Fatalf(\"new credential missing stable uid: %+v\", update.APIKeyConfig[1])\n\t\t}\n\t}\n}\n\nfunc TestProviderRouteNameAndPrimaryResult(t *testing.T) {\n\tbase := \"mimo-test\"\n\ttests := []struct {\n\t\troute config.ProviderRoute\n\t\twant string\n\t}{\n\t\t{route: config.ProviderRoute{ChannelKind: \"messages\"}, want: \"mimo-test-claude\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"chat\"}, want: \"mimo-test-chat\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"responses\"}, want: \"mimo-test-codex\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"gemini\"}, want: \"mimo-test-gemini\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := providerRouteName(base, tt.route, true); got != tt.want {\n\t\t\tt.Fatalf(\"providerRouteName(%s)=%q, want %q\", tt.route.ChannelKind, got, tt.want)\n\t\t}\n\t}\n\tif got := providerRouteName(base, config.ProviderRoute{ChannelKind: \"messages\"}, false); got != base {\n\t\tt.Fatalf(\"single route name=%q, want %q\", got, base)\n\t}\n\n\tresults := []AutoAddChannelResult{\n\t\t{ChannelKind: \"messages\", ChannelUID: \"ch_messages\", Index: 1},\n\t\t{ChannelKind: \"chat\", ChannelUID: \"ch_chat\", Index: 2},\n\t\t{ChannelKind: \"responses\", ChannelUID: \"ch_responses\", Index: 3},\n\t}\n\tprimary := primaryAutoAddResult(results, \"chat\")\n\tif primary.ChannelUID != \"ch_chat\" || primary.Index != 2 {\n\t\tt.Fatalf(\"primary=%+v, want chat result\", primary)\n\t}\n}\n\nfunc TestManagedAccountUIDForProviderUsesExistingAccount(t *testing.T) {\n\tcfg := config.Config{ManagedAccounts: []config.ManagedAccountConfig{\n\t\t{AccountUID: \"acct-old\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-new\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-deepseek\", ProviderID: \"deepseek\"},\n\t}}\n\tif got := managedAccountUIDForProvider(cfg, \"mimo\"); got != \"acct-new\" {\n\t\tt.Fatalf(\"mimo existing account = %q, want acct-new\", got)\n\t}\n\tif got := managedAccountUIDForProvider(cfg, \"unknown\"); got != \"\" {\n\t\tt.Fatalf(\"unknown provider 不应命中账号: %q\", got)\n\t}\n}\n\nfunc TestListManagedAccountsDoesNotExposeVolcengineSecret(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-secret\",\"volcengineAccessKey\":{\"accessKeyId\":\"AKIDEXAMPLE\",\"secretAccessKey\":\"SECRET_MUST_NOT_LEAK\",\"plan\":\"agent_plan\",\"planTier\":\"Large\",\"planStatus\":\"Running\"}}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"SECRET_MUST_NOT_LEAK\") || strings.Contains(body, \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", body)\n\t}\n\tfor _, expected := range []string{`\"hasVolcengineAccessKey\":true`, `\"volcenginePlan\":\"agent_plan\"`, `\"volcenginePlanTier\":\"Large\"`} {\n\t\tif !strings.Contains(body, expected) {\n\t\t\tt.Fatalf(\"响应缺少 %s: %s\", expected, body)\n\t\t}\n\t}\n}\n\nfunc TestSetVolcengineAccessKeyDetectsPlanBeforePersisting(t *testing.T) {\n\tcontrolPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Query().Get(\"Action\") != \"GetPersonalPlan\" {\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tvar body struct{ Plan string }\n\t\t_ = json.NewDecoder(r.Body).Decode(&body)\n\t\tif body.Plan == \"CodingPlan\" {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t_, _ = w.Write([]byte(`{\"ResponseMetadata\":{\"Error\":{\"Code\":\"ResourceNotFound.Plan\",\"Message\":\"not found\"}}}`))\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\"Result\":{\"PlanType\":\"Large\",\"Status\":\"Running\"}}`))\n\t}))\n\tdefer controlPlane.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-inference\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\trunner := NewAutoDiscoveryRunner(nil, nil)\n\trunner.client = controlPlane.Client()\n\trunner.volcengineControlPlaneEndpoint = controlPlane.URL\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, Runner: runner})\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_volc/credentials/cred_volc/volcengine-access-key\", bytes.NewBufferString(`{\"accessKeyId\":\"AKID\",\"secretAccessKey\":\"SECRET\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"plan\":\"agent_plan\"`) {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_volc\", \"cred_volc\")\n\tif !ok || credential.VolcengineAccessKey == nil || credential.VolcengineAccessKey.SecretAccessKey != \"SECRET\" || credential.VolcengineAccessKey.Plan != \"agent_plan\" {\n\t\tt.Fatalf(\"凭证未正确持久化: %+v\", credential)\n\t}\n}\n\nfunc TestSetMiMoConsoleCookieRequiresConfirmationBeforeAdoptingKey(t *testing.T) {\n\tconst oldKey = \"tp-old-1234567890123456789012345678901234567890\"\n\tconst cookieKey = \"tp-new-1234567890123456789012345678901234567890\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != \"api-platform_serviceToken=session; userId=42\" {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase \"/api/v1/userProfile\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"userId\":\"42\"}}`))\n\t\tcase \"/api/v1/tokenPlan/detail\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"planCode\":\"max\",\"planName\":\"Max\",\"currentPeriodEnd\":\"2026-07-29 23:59:59\"}}`))\n\t\tcase \"/api/v1/tokenPlan/usage\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"monthUsage\":{\"percent\":0.25,\"items\":[{\"used\":25,\"limit\":100}]},\"usage\":{\"percent\":0.5,\"items\":[{\"used\":50,\"limit\":100}]}}}`))\n\t\tcase \"/api/v1/tokenPlan/apiKey/raw\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":\"` + cookieKey + `\"}`))\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}\n\t}))\n\tdefer console.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"credentials\":[{\"credentialUid\":\"cred_mimo\",\"apiKey\":\"` + oldKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_mimo\",\"channelUid\":\"ch_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_mimo\",\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\tdeps := &AutoManagedDeps{CfgManager: manager, MiMoConsoleClient: &MiMoConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL}}\n\trouter := setupAutoManagedRouter(deps)\n\tbody := `{\"cookie\":\"api-platform_serviceToken=session; userId=42\"}`\n\trequest := func(payload string) *httptest.ResponseRecorder {\n\t\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_mimo/credentials/cred_mimo/mimo-console-cookie\", bytes.NewBufferString(payload))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tw := httptest.NewRecorder()\n\t\trouter.ServeHTTP(w, req)\n\t\treturn w\n\t}\n\tw := request(body)\n\tif w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), `\"code\":\"mimo_cookie_key_mismatch\"`) {\n\t\tt.Fatalf(\"未要求确认: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ := manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != oldKey || credential.MiMoConsole != nil {\n\t\tt.Fatalf(\"确认前不应修改配置: %+v\", credential)\n\t}\n\tw = request(`{\"cookie\":\"api-platform_serviceToken=session; userId=42\",\"adoptCookieKey\":true}`)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"keyAdopted\":true`) {\n\t\tt.Fatalf(\"确认采用失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ = manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != cookieKey || credential.MiMoConsole == nil {\n\t\tt.Fatalf(\"确认后未采用 Cookie Key: %+v\", credential)\n\t}\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif strings.Contains(list.Body.String(), \"api-platform_serviceToken\") || strings.Contains(list.Body.String(), cookieKey) {\n\t\tt.Fatalf(\"账号列表泄漏 Cookie 或原始 Key: %s\", list.Body.String())\n\t}\n}\n\nfunc TestProviderAutoAddReusesExistingAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(`{\"providerId\":\"mimo\",\"apiKeys\":[\"sk-existing\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"追加已有 provider key status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 4 {\n\t\tt.Fatalf(\"旧账号应自动补齐模板中的协议渠道: %+v\", channels)\n\t}\n\tkinds := make(map[string]bool, len(channels))\n\tfor _, channel := range channels {\n\t\tkinds[channel.Kind] = true\n\t}\n\tfor _, kind := range []string{\"messages\", \"chat\", \"responses\", \"gemini\"} {\n\t\tif !kinds[kind] {\n\t\t\tt.Fatalf(\"旧账号缺少 %s route: %+v\", kind, channels)\n\t\t}\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.AccountUID != \"acct_test\" {\n\t\tt.Fatalf(\"响应未返回已有账号: body=%s err=%v\", w.Body.String(), err)\n\t}\n\tif len(response.Channels) != 4 {\n\t\tt.Fatalf(\"响应未返回补齐后的全部渠道: %+v\", response.Channels)\n\t}\n}\n\nfunc TestMissingProviderAccountRoutes(t *testing.T) {\n\ttmpl, ok := config.GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"缺少 deepseek provider 模板\")\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{ServiceType: \"claude\"},\n\t}}\n\tmissing := missingProviderAccountRoutes(tmpl, existing)\n\tif len(missing) != 2 || missing[0].ChannelKind != \"chat\" || missing[1].ChannelKind != \"responses\" {\n\t\tt.Fatalf(\"missing routes = %+v\", missing)\n\t}\n\tconfigs, baseURLs, err := bindProviderRouteKeys(tmpl, missing[0], []string{\"sk-existing\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(configs) != 1 || configs[0].BaseURL != \"https://api.deepseek.com\" || len(baseURLs) != 1 {\n\t\tt.Fatalf(\"route 绑定结果不正确: configs=%+v baseURLs=%v\", configs, baseURLs)\n\t}\n}\n\nfunc TestBindProviderRouteKeysPreservesExistingEndpointAffinity(t *testing.T) {\n\ttests := []struct {\n\t\tproviderID string\n\t\tapiKey string\n\t\texistingBaseURL string\n\t\ttargetKind string\n\t\twantBaseURL string\n\t}{\n\t\t{\n\t\t\tproviderID: \"mimo\", apiKey: \"tp-existing\",\n\t\t\texistingBaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\t\ttargetKind: \"chat\", wantBaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\",\n\t\t},\n\t\t{\n\t\t\tproviderID: \"volcengine\", apiKey: \"ark-existing\",\n\t\t\texistingBaseURL: \"https://ark.cn-beijing.volces.com/api/coding\",\n\t\t\ttargetKind: \"responses\", wantBaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.providerID, func(t *testing.T) {\n\t\t\ttmpl, ok := config.GetProviderTemplate(tt.providerID)\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"缺少 provider 模板: %s\", tt.providerID)\n\t\t\t}\n\t\t\texisting := []config.AccountChannel{{\n\t\t\t\tKind: \"messages\",\n\t\t\t\tUpstream: config.UpstreamConfig{\n\t\t\t\t\tProviderID: tt.providerID, ServiceType: \"claude\", APIKeys: []string{tt.apiKey},\n\t\t\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: tt.apiKey, BaseURL: tt.existingBaseURL}},\n\t\t\t\t},\n\t\t\t}}\n\t\t\tvar target config.ProviderRoute\n\t\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\t\tif route.ChannelKind == tt.targetKind {\n\t\t\t\t\ttarget = route\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tconfigs, _, err := bindProviderRouteKeysWithAffinities(\n\t\t\t\ttmpl, target, []string{tt.apiKey}, providerKeyCandidateAffinities(tmpl, existing),\n\t\t\t)\n\t\t\tif err != nil || len(configs) != 1 || configs[0].BaseURL != tt.wantBaseURL {\n\t\t\t\tt.Fatalf(\"亲和绑定错误: configs=%+v err=%v want=%s\", configs, err, tt.wantBaseURL)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPlanProviderAccountRouteAdditionsRejectsNewKeyBeforeMutation(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t}))\n\tdefer server.Close()\n\n\ttmpl := &config.ProviderTemplate{\n\t\tProviderID: \"test-provider\",\n\t\tRoutes: []config.ProviderRoute{\n\t\t\t{ChannelKind: \"messages\", ServiceType: \"claude\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/anthropic\"}}},\n\t\t\t{ChannelKind: \"chat\", ServiceType: \"openai\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/v1\"}}},\n\t\t},\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct_test\", ChannelUID: \"ch_messages\", Name: \"test-provider\",\n\t\t\tProviderID: \"test-provider\", ServiceType: \"claude\", AutoManaged: true, APIKeys: []string{\"sk-existing\"},\n\t\t},\n\t}}\n\tadditions, status, err := planProviderAccountRouteAdditions(\n\t\tt.Context(), config.Config{}, tmpl, \"acct_test\", []string{\"sk-existing\", \"sk-invalid\"}, existing,\n\t)\n\tif err == nil || status != http.StatusBadRequest || len(additions) != 0 {\n\t\tt.Fatalf(\"无效新 Key 应在生成新增 route 前失败: status=%d additions=%+v err=%v\", status, additions, err)\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}, "696dbecc5213b89fc9f89dc8c3958322cf411622": {"t": 1785759350.1407356, "n": 39360, "h": "9a90f29e81707026", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestListAccountsMasksCredentials(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-secret-value\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil)\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"sk-secret-value\") {\n\t\tt.Fatalf(\"账号列表泄露明文 Key: %s\", body)\n\t}\n\tif !strings.Contains(body, \"cred_test\") || !strings.Contains(body, \"keyMask\") {\n\t\tt.Fatalf(\"账号列表缺少凭证掩码信息: %s\", body)\n\t}\n}\n\nfunc TestPatchAccountCredentialsRemovesByUID(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"sk-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"sk-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"baseUrls\":[\"https://api.xiaomimimo.com/anthropic\"],\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPatch, \"/api/accounts/acct_test/credentials\", bytes.NewBufferString(`{\"removeCredentialUids\":[\"cred_b\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PATCH credentials status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 1 || len(channels[0].Upstream.APIKeys) != 1 || channels[0].Upstream.APIKeys[0] != \"sk-a\" {\n\t\tt.Fatalf(\"按 credentialUid 删除失败: %+v\", channels)\n\t}\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestInferAutoAddProviderID(t *testing.T) {\n\tzhipuKey := \"0123456789abcdef0123456789abcdef.ABCDEFGHIJKLMNO1\"\n\ttests := []struct {\n\t\tname string\n\t\tbaseURLs []string\n\t\tapiKeys []string\n\t\twant string\n\t}{\n\t\t{name: \"官方 Claude URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"官方 OpenAI URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4/\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"两个官方协议 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\", \"https://open.bigmodel.cn/api/paas/v4\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"仅智谱 Key\", apiKeys: []string{zhipuKey}, want: \"glm\"},\n\t\t{name: \"第三方 URL 不按 Key 提升\", baseURLs: []string{\"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"混合官方与第三方 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4\", \"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"共享 sk Key 无法推断\", apiKeys: []string{\"sk-abcdefghijklmnopqrstuvwxyz123456\"}},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := inferAutoAddProviderID(tt.baseURLs, tt.apiKeys); got != tt.want {\n\t\t\t\tt.Fatalf(\"inferAutoAddProviderID() = %q, want %q\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestApplyProviderUpstreamDefaults(t *testing.T) {\n\tglmChat := config.UpstreamConfig{ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmChat)\n\tif glmChat.ReasoningParamStyle != \"reasoning_effort\" || !glmChat.PassbackReasoningContent {\n\t\tt.Fatalf(\"GLM OpenAI 默认兼容参数未补齐: %+v\", glmChat)\n\t}\n\n\tglmClaude := config.UpstreamConfig{ServiceType: \"claude\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmClaude)\n\tif glmClaude.ReasoningParamStyle != \"\" || glmClaude.PassbackReasoningContent {\n\t\tt.Fatalf(\"GLM Claude 原生 route 不应注入 OpenAI 参数: %+v\", glmClaude)\n\t}\n\n\tcustom := config.UpstreamConfig{ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"\", &custom)\n\tif custom.ReasoningParamStyle != \"\" || custom.PassbackReasoningContent {\n\t\tt.Fatalf(\"自定义渠道不应应用 GLM 默认值: %+v\", custom)\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCustomAutoAddResponseIncludesActualRoute(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\tdiscoverer := NewRateLimitDiscoverer(RateLimitDiscovererConfig{QuietLogs: true})\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, RateLimitDiscoverer: discoverer})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"fastaitoken-com-test\",\"baseUrls\":[\"https://example.com\"],\"apiKeys\":[\"sk-test\"],\"rateLimitHint\":{\"initialRpm\":30,\"effectiveRpm\":15,\"rateLimited\":true,\"rateLimitedCount\":1}}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 {\n\t\tt.Fatalf(\"channels=%+v, want one actual route\", response.Channels)\n\t}\n\troute := response.Channels[0]\n\tif route.ChannelKind != \"responses\" || route.ServiceType != \"responses\" || route.ChannelUID != response.ChannelUID {\n\t\tt.Fatalf(\"route=%+v response=%+v\", route, response)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.ResponsesUpstream) != 1 || len(cfg.Upstream) != 0 {\n\t\tt.Fatalf(\"custom route persisted in wrong channel: responses=%d messages=%d\", len(cfg.ResponsesUpstream), len(cfg.Upstream))\n\t}\n\tcreated := cfg.ResponsesUpstream[0]\n\tbaseURL := created.GetEffectiveBaseURL()\n\tendpointUID := GenerateEndpointUID(created.ChannelUID, baseURL, KeyHashFromAPIKey(\"sk-test\"))\n\tif suggestion := discoverer.SuggestedLimit(endpointUID); suggestion.RPM != 15 || suggestion.Confidence != 0.7 {\n\t\tt.Fatalf(\"rate limit suggestion=%+v, want rpm=15 confidence=0.7\", suggestion)\n\t}\n\n\tstore := newTestProfileStore(t)\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\tseedAutoAddRateLimitHint(\n\t\t&AutoManagedDeps{CfgManager: manager, Runner: runner, RateLimitDiscoverer: discoverer},\n\t\tmanager.GetAccountChannels(response.AccountUID),\n\t\t&AutoAddRateLimitHint{InitialRPM: 30, EffectiveRPM: 15, RateLimited: true, RateLimitedCount: 1},\n\t\t\"sk-test\",\n\t\t\"https://example.com\",\n\t)\n\tprofile := store.Get(endpointUID)\n\tif profile == nil || profile.DiscoveredRPM != 15 || profile.RateLimitConfidence != 0.7 || profile.RateLimitSource != string(RateLimitSourcePassiveAIMD) {\n\t\tt.Fatalf(\"rate limit profile=%+v\", profile)\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyToEquivalentExistingBaseURL(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [],\n \"chatUpstream\": [{\"channelUid\":\"ch_existing\",\"name\":\"localhost-old-name\",\"serviceType\":\"openai\",\"baseUrl\":\"http://localhost:8990/v1\",\"apiKeys\":[\"sk-old\"],\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"localhost-8990\",\"baseUrls\":[\"http://localhost:8990/\"],\"apiKeys\":[\"sk-new\"]}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 || response.Channels[0].Name != \"localhost-old-name\" {\n\t\tt.Fatalf(\"应返回已有渠道: %+v\", response.Channels)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 0 || len(cfg.ChatUpstream) != 1 {\n\t\tt.Fatalf(\"重复请求不应创建新渠道: messages=%d chat=%d\", len(cfg.Upstream), len(cfg.ChatUpstream))\n\t}\n\tif got := strings.Join(cfg.ChatUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"已有渠道密钥=%q, want sk-old,sk-new\", got)\n\t}\n}\n\nfunc TestFindExistingAutoAddChannelsPreservesHashSemantics(t *testing.T) {\n\tcfg := config.Config{\n\t\tChatUpstream: []config.UpstreamConfig{{\n\t\t\tChannelUID: \"ch-hash\", ServiceType: \"openai\", BaseURL: \"https://example.com#\",\n\t\t}},\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com\"}); len(matches) != 0 {\n\t\tt.Fatalf(\"普通 Base URL 不应匹配禁止版本前缀的地址: %+v\", matches)\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com/#\"}); len(matches) != 1 {\n\t\tt.Fatalf(\"等效 # 地址匹配数量=%d, want 1\", len(matches))\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyAndMissingRouteToManagedAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [{\"accountUid\":\"acct_existing\",\"channelUid\":\"ch_existing\",\"name\":\"localhost-8990\",\"serviceType\":\"claude\",\"baseUrl\":\"http://localhost:8990\",\"apiKeys\":[\"sk-old\"],\"autoManaged\":true,\"status\":\"active\"}],\n \"chatUpstream\": [], \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"localhost-8990\",\n \"baseUrls\":[\"http://localhost:8990/v1\"],\n \"apiKeys\":[\"sk-new\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"claude-test\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"gpt-test\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d\", len(cfg.Upstream), len(cfg.ResponsesUpstream))\n\t}\n\tif cfg.Upstream[0].AccountUID != \"acct_existing\" || cfg.ResponsesUpstream[0].AccountUID != \"acct_existing\" {\n\t\tt.Fatalf(\"应复用已有账号: messages=%q responses=%q\", cfg.Upstream[0].AccountUID, cfg.ResponsesUpstream[0].AccountUID)\n\t}\n\tif got := strings.Join(cfg.Upstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"messages 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif got := strings.Join(cfg.ResponsesUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"responses 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif cfg.Upstream[0].Name != \"localhost-8990-claude\" || cfg.ResponsesUpstream[0].Name != \"localhost-8990-codex\" {\n\t\tt.Fatalf(\"多协议自动命名错误: messages=%q responses=%q\", cfg.Upstream[0].Name, cfg.ResponsesUpstream[0].Name)\n\t}\n}\n\nfunc TestCustomAutoAddCreatesAllDetectedRoutesWithProtocolModels(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"fastaitoken-com-test\",\n \"baseUrls\":[\"https://example.com/keys\"],\n \"apiKeys\":[\"sk-test\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"shared\",\"messages-only\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"shared\",\"responses-only\"]},\n {\"channelKind\":\"chat\",\"supportedModels\":[\"shared\",\"chat-only\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 3 {\n\t\tt.Fatalf(\"channels=%+v, want three detected routes\", response.Channels)\n\t}\n\tfor _, route := range response.Channels {\n\t\tif route.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route account=%q, want %q\", route.AccountUID, response.AccountUID)\n\t\t}\n\t}\n\tif response.Channels[1].ChannelKind != \"responses\" || response.ChannelUID != response.Channels[1].ChannelUID {\n\t\tt.Fatalf(\"请求协议应作为主路由: response=%+v\", response)\n\t}\n\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 || len(cfg.ChatUpstream) != 1 || len(cfg.GeminiUpstream) != 0 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d chat=%d gemini=%d\",\n\t\t\tlen(cfg.Upstream), len(cfg.ResponsesUpstream), len(cfg.ChatUpstream), len(cfg.GeminiUpstream))\n\t}\n\tchecks := []struct {\n\t\tchannel config.UpstreamConfig\n\t\tname string\n\t\tmodels []string\n\t}{\n\t\t{cfg.Upstream[0], \"fastaitoken-com-test-claude\", []string{\"shared\", \"messages-only\"}},\n\t\t{cfg.ResponsesUpstream[0], \"fastaitoken-com-test-codex\", []string{\"shared\", \"responses-only\"}},\n\t\t{cfg.ChatUpstream[0], \"fastaitoken-com-test-chat\", []string{\"shared\", \"chat-only\"}},\n\t}\n\tfor _, check := range checks {\n\t\tif check.channel.Name != check.name || check.channel.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route identity=%+v, want name=%q account=%q\", check.channel, check.name, response.AccountUID)\n\t\t}\n\t\tif strings.Join(check.channel.SupportedModels, \",\") != strings.Join(check.models, \",\") {\n\t\t\tt.Fatalf(\"route %s models=%v, want %v\", check.name, check.channel.SupportedModels, check.models)\n\t\t}\n\t}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesRenamesAndSyncsCredentials(t *testing.T) {\n\tchannels := []config.AccountChannel{\n\t\t{Kind: \"messages\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-messages\", Name: \"old-claude\",\n\t\t\tServiceType: \"claude\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t\t{Kind: \"responses\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-responses\", Name: \"old-codex\",\n\t\t\tServiceType: \"responses\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t}\n\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\tName: \"renamed\", APIKeys: []string{\"sk-old\", \"sk-new\"},\n\t}, channels, len(channels))\n\tif err != nil || status != http.StatusOK {\n\t\tt.Fatalf(\"status=%d err=%v\", status, err)\n\t}\n\tif len(updates) != 2 || updates[0].Name != \"renamed-claude\" || updates[1].Name != \"renamed-codex\" {\n\t\tt.Fatalf(\"route names=%+v\", updates)\n\t}\n\tfor _, update := range updates {\n\t\tif len(update.APIKeyConfig) != 2 || update.APIKeyConfig[1].BaseURL != \"https://example.com\" {\n\t\t\tt.Fatalf(\"route credentials=%+v\", update.APIKeyConfig)\n\t\t}\n\t\tif update.APIKeyConfig[1].CredentialUID == \"\" {\n\t\t\tt.Fatalf(\"new credential missing stable uid: %+v\", update.APIKeyConfig[1])\n\t\t}\n\t}\n}\n\nfunc TestProviderRouteNameAndPrimaryResult(t *testing.T) {\n\tbase := \"mimo-test\"\n\ttests := []struct {\n\t\troute config.ProviderRoute\n\t\twant string\n\t}{\n\t\t{route: config.ProviderRoute{ChannelKind: \"messages\"}, want: \"mimo-test-claude\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"chat\"}, want: \"mimo-test-chat\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"responses\"}, want: \"mimo-test-codex\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"gemini\"}, want: \"mimo-test-gemini\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := providerRouteName(base, tt.route, true); got != tt.want {\n\t\t\tt.Fatalf(\"providerRouteName(%s)=%q, want %q\", tt.route.ChannelKind, got, tt.want)\n\t\t}\n\t}\n\tif got := providerRouteName(base, config.ProviderRoute{ChannelKind: \"messages\"}, false); got != base {\n\t\tt.Fatalf(\"single route name=%q, want %q\", got, base)\n\t}\n\n\tresults := []AutoAddChannelResult{\n\t\t{ChannelKind: \"messages\", ChannelUID: \"ch_messages\", Index: 1},\n\t\t{ChannelKind: \"chat\", ChannelUID: \"ch_chat\", Index: 2},\n\t\t{ChannelKind: \"responses\", ChannelUID: \"ch_responses\", Index: 3},\n\t}\n\tprimary := primaryAutoAddResult(results, \"chat\")\n\tif primary.ChannelUID != \"ch_chat\" || primary.Index != 2 {\n\t\tt.Fatalf(\"primary=%+v, want chat result\", primary)\n\t}\n}\n\nfunc TestManagedAccountUIDForProviderUsesExistingAccount(t *testing.T) {\n\tcfg := config.Config{ManagedAccounts: []config.ManagedAccountConfig{\n\t\t{AccountUID: \"acct-old\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-new\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-deepseek\", ProviderID: \"deepseek\"},\n\t}}\n\tif got := managedAccountUIDForProvider(cfg, \"mimo\"); got != \"acct-new\" {\n\t\tt.Fatalf(\"mimo existing account = %q, want acct-new\", got)\n\t}\n\tif got := managedAccountUIDForProvider(cfg, \"unknown\"); got != \"\" {\n\t\tt.Fatalf(\"unknown provider 不应命中账号: %q\", got)\n\t}\n}\n\nfunc TestListManagedAccountsDoesNotExposeVolcengineSecret(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-secret\",\"volcengineAccessKey\":{\"accessKeyId\":\"AKIDEXAMPLE\",\"secretAccessKey\":\"SECRET_MUST_NOT_LEAK\",\"plan\":\"agent_plan\",\"planTier\":\"Large\",\"planStatus\":\"Running\"}}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"SECRET_MUST_NOT_LEAK\") || strings.Contains(body, \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", body)\n\t}\n\tfor _, expected := range []string{`\"hasVolcengineAccessKey\":true`, `\"volcenginePlan\":\"agent_plan\"`, `\"volcenginePlanTier\":\"Large\"`} {\n\t\tif !strings.Contains(body, expected) {\n\t\t\tt.Fatalf(\"响应缺少 %s: %s\", expected, body)\n\t\t}\n\t}\n}\n\nfunc TestSetVolcengineAccessKeyDetectsPlanBeforePersisting(t *testing.T) {\n\tcontrolPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Query().Get(\"Action\") != \"GetPersonalPlan\" {\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tvar body struct{ Plan string }\n\t\t_ = json.NewDecoder(r.Body).Decode(&body)\n\t\tif body.Plan == \"CodingPlan\" {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t_, _ = w.Write([]byte(`{\"ResponseMetadata\":{\"Error\":{\"Code\":\"ResourceNotFound.Plan\",\"Message\":\"not found\"}}}`))\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\"Result\":{\"PlanType\":\"Large\",\"Status\":\"Running\"}}`))\n\t}))\n\tdefer controlPlane.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-inference\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\trunner := NewAutoDiscoveryRunner(nil, nil)\n\trunner.client = controlPlane.Client()\n\trunner.volcengineControlPlaneEndpoint = controlPlane.URL\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, Runner: runner})\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_volc/credentials/cred_volc/volcengine-access-key\", bytes.NewBufferString(`{\"accessKeyId\":\"AKID\",\"secretAccessKey\":\"SECRET\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"plan\":\"agent_plan\"`) {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_volc\", \"cred_volc\")\n\tif !ok || credential.VolcengineAccessKey == nil || credential.VolcengineAccessKey.SecretAccessKey != \"SECRET\" || credential.VolcengineAccessKey.Plan != \"agent_plan\" {\n\t\tt.Fatalf(\"凭证未正确持久化: %+v\", credential)\n\t}\n}\n\nfunc TestSetMiMoConsoleCookieRequiresConfirmationBeforeAdoptingKey(t *testing.T) {\n\tconst oldKey = \"tp-old-1234567890123456789012345678901234567890\"\n\tconst cookieKey = \"tp-new-1234567890123456789012345678901234567890\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != \"api-platform_serviceToken=session; userId=42\" {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase \"/api/v1/userProfile\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"userId\":\"42\"}}`))\n\t\tcase \"/api/v1/tokenPlan/detail\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"planCode\":\"max\",\"planName\":\"Max\",\"currentPeriodEnd\":\"2026-07-29 23:59:59\"}}`))\n\t\tcase \"/api/v1/tokenPlan/usage\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"monthUsage\":{\"percent\":0.25,\"items\":[{\"used\":25,\"limit\":100}]},\"usage\":{\"percent\":0.5,\"items\":[{\"used\":50,\"limit\":100}]}}}`))\n\t\tcase \"/api/v1/tokenPlan/apiKey/raw\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":\"` + cookieKey + `\"}`))\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}\n\t}))\n\tdefer console.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"credentials\":[{\"credentialUid\":\"cred_mimo\",\"apiKey\":\"` + oldKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_mimo\",\"channelUid\":\"ch_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_mimo\",\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\tdeps := &AutoManagedDeps{CfgManager: manager, MiMoConsoleClient: &MiMoConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL}}\n\trouter := setupAutoManagedRouter(deps)\n\tbody := `{\"cookie\":\"api-platform_serviceToken=session; userId=42\"}`\n\trequest := func(payload string) *httptest.ResponseRecorder {\n\t\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_mimo/credentials/cred_mimo/mimo-console-cookie\", bytes.NewBufferString(payload))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tw := httptest.NewRecorder()\n\t\trouter.ServeHTTP(w, req)\n\t\treturn w\n\t}\n\tw := request(body)\n\tif w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), `\"code\":\"mimo_cookie_key_mismatch\"`) {\n\t\tt.Fatalf(\"未要求确认: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ := manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != oldKey || credential.MiMoConsole != nil {\n\t\tt.Fatalf(\"确认前不应修改配置: %+v\", credential)\n\t}\n\tw = request(`{\"cookie\":\"api-platform_serviceToken=session; userId=42\",\"adoptCookieKey\":true}`)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"keyAdopted\":true`) {\n\t\tt.Fatalf(\"确认采用失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ = manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != cookieKey || credential.MiMoConsole == nil {\n\t\tt.Fatalf(\"确认后未采用 Cookie Key: %+v\", credential)\n\t}\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif strings.Contains(list.Body.String(), \"api-platform_serviceToken\") || strings.Contains(list.Body.String(), cookieKey) {\n\t\tt.Fatalf(\"账号列表泄漏 Cookie 或原始 Key: %s\", list.Body.String())\n\t}\n}\n\nfunc TestProviderAutoAddReusesExistingAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(`{\"providerId\":\"mimo\",\"apiKeys\":[\"sk-existing\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"追加已有 provider key status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 4 {\n\t\tt.Fatalf(\"旧账号应自动补齐模板中的协议渠道: %+v\", channels)\n\t}\n\tkinds := make(map[string]bool, len(channels))\n\tfor _, channel := range channels {\n\t\tkinds[channel.Kind] = true\n\t}\n\tfor _, kind := range []string{\"messages\", \"chat\", \"responses\", \"gemini\"} {\n\t\tif !kinds[kind] {\n\t\t\tt.Fatalf(\"旧账号缺少 %s route: %+v\", kind, channels)\n\t\t}\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.AccountUID != \"acct_test\" {\n\t\tt.Fatalf(\"响应未返回已有账号: body=%s err=%v\", w.Body.String(), err)\n\t}\n\tif len(response.Channels) != 4 {\n\t\tt.Fatalf(\"响应未返回补齐后的全部渠道: %+v\", response.Channels)\n\t}\n}\n\nfunc TestMissingProviderAccountRoutes(t *testing.T) {\n\ttmpl, ok := config.GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"缺少 deepseek provider 模板\")\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{ServiceType: \"claude\"},\n\t}}\n\tmissing := missingProviderAccountRoutes(tmpl, existing)\n\tif len(missing) != 2 || missing[0].ChannelKind != \"chat\" || missing[1].ChannelKind != \"responses\" {\n\t\tt.Fatalf(\"missing routes = %+v\", missing)\n\t}\n\tconfigs, baseURLs, err := bindProviderRouteKeys(tmpl, missing[0], []string{\"sk-existing\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(configs) != 1 || configs[0].BaseURL != \"https://api.deepseek.com\" || len(baseURLs) != 1 {\n\t\tt.Fatalf(\"route 绑定结果不正确: configs=%+v baseURLs=%v\", configs, baseURLs)\n\t}\n}\n\nfunc TestBindProviderRouteKeysPreservesExistingEndpointAffinity(t *testing.T) {\n\ttests := []struct {\n\t\tproviderID string\n\t\tapiKey string\n\t\texistingBaseURL string\n\t\ttargetKind string\n\t\twantBaseURL string\n\t}{\n\t\t{\n\t\t\tproviderID: \"mimo\", apiKey: \"tp-existing\",\n\t\t\texistingBaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\t\ttargetKind: \"chat\", wantBaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\",\n\t\t},\n\t\t{\n\t\t\tproviderID: \"volcengine\", apiKey: \"ark-existing\",\n\t\t\texistingBaseURL: \"https://ark.cn-beijing.volces.com/api/coding\",\n\t\t\ttargetKind: \"responses\", wantBaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.providerID, func(t *testing.T) {\n\t\t\ttmpl, ok := config.GetProviderTemplate(tt.providerID)\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"缺少 provider 模板: %s\", tt.providerID)\n\t\t\t}\n\t\t\texisting := []config.AccountChannel{{\n\t\t\t\tKind: \"messages\",\n\t\t\t\tUpstream: config.UpstreamConfig{\n\t\t\t\t\tProviderID: tt.providerID, ServiceType: \"claude\", APIKeys: []string{tt.apiKey},\n\t\t\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: tt.apiKey, BaseURL: tt.existingBaseURL}},\n\t\t\t\t},\n\t\t\t}}\n\t\t\tvar target config.ProviderRoute\n\t\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\t\tif route.ChannelKind == tt.targetKind {\n\t\t\t\t\ttarget = route\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tconfigs, _, err := bindProviderRouteKeysWithAffinities(\n\t\t\t\ttmpl, target, []string{tt.apiKey}, providerKeyCandidateAffinities(tmpl, existing),\n\t\t\t)\n\t\t\tif err != nil || len(configs) != 1 || configs[0].BaseURL != tt.wantBaseURL {\n\t\t\t\tt.Fatalf(\"亲和绑定错误: configs=%+v err=%v want=%s\", configs, err, tt.wantBaseURL)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPlanProviderAccountRouteAdditionsRejectsNewKeyBeforeMutation(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t}))\n\tdefer server.Close()\n\n\ttmpl := &config.ProviderTemplate{\n\t\tProviderID: \"test-provider\",\n\t\tRoutes: []config.ProviderRoute{\n\t\t\t{ChannelKind: \"messages\", ServiceType: \"claude\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/anthropic\"}}},\n\t\t\t{ChannelKind: \"chat\", ServiceType: \"openai\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/v1\"}}},\n\t\t},\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct_test\", ChannelUID: \"ch_messages\", Name: \"test-provider\",\n\t\t\tProviderID: \"test-provider\", ServiceType: \"claude\", AutoManaged: true, APIKeys: []string{\"sk-existing\"},\n\t\t},\n\t}}\n\tadditions, status, err := planProviderAccountRouteAdditions(\n\t\tt.Context(), config.Config{}, tmpl, \"acct_test\", []string{\"sk-existing\", \"sk-invalid\"}, existing,\n\t)\n\tif err == nil || status != http.StatusBadRequest || len(additions) != 0 {\n\t\tt.Fatalf(\"无效新 Key 应在生成新增 route 前失败: status=%d additions=%+v err=%v\", status, additions, err)\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}, "c37b88af3f461fcddd8d23112b865c6c08dee729": {"t": 1785759352.328803, "n": 33554, "h": "f332e09177066ee5", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestListAccountsMasksCredentials(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-secret-value\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil)\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"sk-secret-value\") {\n\t\tt.Fatalf(\"账号列表泄露明文 Key: %s\", body)\n\t}\n\tif !strings.Contains(body, \"cred_test\") || !strings.Contains(body, \"keyMask\") {\n\t\tt.Fatalf(\"账号列表缺少凭证掩码信息: %s\", body)\n\t}\n}\n\nfunc TestPatchAccountCredentialsRemovesByUID(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"sk-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"sk-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"baseUrls\":[\"https://api.xiaomimimo.com/anthropic\"],\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPatch, \"/api/accounts/acct_test/credentials\", bytes.NewBufferString(`{\"removeCredentialUids\":[\"cred_b\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PATCH credentials status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 1 || len(channels[0].Upstream.APIKeys) != 1 || channels[0].Upstream.APIKeys[0] != \"sk-a\" {\n\t\tt.Fatalf(\"按 credentialUid 删除失败: %+v\", channels)\n\t}\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestInferAutoAddProviderID(t *testing.T) {\n\tzhipuKey := \"0123456789abcdef0123456789abcdef.ABCDEFGHIJKLMNO1\"\n\ttests := []struct {\n\t\tname string\n\t\tbaseURLs []string\n\t\tapiKeys []string\n\t\twant string\n\t}{\n\t\t{name: \"官方 Claude URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"官方 OpenAI URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4/\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"两个官方协议 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\", \"https://open.bigmodel.cn/api/paas/v4\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"仅智谱 Key\", apiKeys: []string{zhipuKey}, want: \"glm\"},\n\t\t{name: \"第三方 URL 不按 Key 提升\", baseURLs: []string{\"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"混合官方与第三方 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4\", \"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"共享 sk Key 无法推断\", apiKeys: []string{\"sk-abcdefghijklmnopqrstuvwxyz123456\"}},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := inferAutoAddProviderID(tt.baseURLs, tt.apiKeys); got != tt.want {\n\t\t\t\tt.Fatalf(\"inferAutoAddProviderID() = %q, want %q\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestApplyProviderUpstreamDefaults(t *testing.T) {\n\tglmChat := config.UpstreamConfig{ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmChat)\n\tif glmChat.ReasoningParamStyle != \"reasoning_effort\" || !glmChat.PassbackReasoningContent {\n\t\tt.Fatalf(\"GLM OpenAI 默认兼容参数未补齐: %+v\", glmChat)\n\t}\n\n\tglmClaude := config.UpstreamConfig{ServiceType: \"claude\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmClaude)\n\tif glmClaude.ReasoningParamStyle != \"\" || glmClaude.PassbackReasoningContent {\n\t\tt.Fatalf(\"GLM Claude 原生 route 不应注入 OpenAI 参数: %+v\", glmClaude)\n\t}\n\n\tcustom := config.UpstreamConfig{ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"\", &custom)\n\tif custom.ReasoningParamStyle != \"\" || custom.PassbackReasoningContent {\n\t\tt.Fatalf(\"自定义渠道不应应用 GLM 默认值: %+v\", custom)\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCustomAutoAddResponseIncludesActualRoute(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"fastaitoken-com-test\",\"baseUrls\":[\"https://example.com\"],\"apiKeys\":[\"sk-test\"]}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 {\n\t\tt.Fatalf(\"channels=%+v, want one actual route\", response.Channels)\n\t}\n\troute := response.Channels[0]\n\tif route.ChannelKind != \"responses\" || route.ServiceType != \"responses\" || route.ChannelUID != response.ChannelUID {\n\t\tt.Fatalf(\"route=%+v response=%+v\", route, response)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.ResponsesUpstream) != 1 || len(cfg.Upstream) != 0 {\n\t\tt.Fatalf(\"custom route persisted in wrong channel: responses=%d messages=%d\", len(cfg.ResponsesUpstream), len(cfg.Upstream))\n\t}\n}\n\nfunc TestCustomAutoAddCreatesAllDetectedRoutesWithProtocolModels(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"fastaitoken-com-test\",\n \"baseUrls\":[\"https://example.com/keys\"],\n \"apiKeys\":[\"sk-test\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"shared\",\"messages-only\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"shared\",\"responses-only\"]},\n {\"channelKind\":\"chat\",\"supportedModels\":[\"shared\",\"chat-only\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 3 {\n\t\tt.Fatalf(\"channels=%+v, want three detected routes\", response.Channels)\n\t}\n\tfor _, route := range response.Channels {\n\t\tif route.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route account=%q, want %q\", route.AccountUID, response.AccountUID)\n\t\t}\n\t}\n\tif response.Channels[1].ChannelKind != \"responses\" || response.ChannelUID != response.Channels[1].ChannelUID {\n\t\tt.Fatalf(\"请求协议应作为主路由: response=%+v\", response)\n\t}\n\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 || len(cfg.ChatUpstream) != 1 || len(cfg.GeminiUpstream) != 0 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d chat=%d gemini=%d\",\n\t\t\tlen(cfg.Upstream), len(cfg.ResponsesUpstream), len(cfg.ChatUpstream), len(cfg.GeminiUpstream))\n\t}\n\tchecks := []struct {\n\t\tchannel config.UpstreamConfig\n\t\tname string\n\t\tmodels []string\n\t}{\n\t\t{cfg.Upstream[0], \"fastaitoken-com-test-claude\", []string{\"shared\", \"messages-only\"}},\n\t\t{cfg.ResponsesUpstream[0], \"fastaitoken-com-test-codex\", []string{\"shared\", \"responses-only\"}},\n\t\t{cfg.ChatUpstream[0], \"fastaitoken-com-test-chat\", []string{\"shared\", \"chat-only\"}},\n\t}\n\tfor _, check := range checks {\n\t\tif check.channel.Name != check.name || check.channel.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route identity=%+v, want name=%q account=%q\", check.channel, check.name, response.AccountUID)\n\t\t}\n\t\tif strings.Join(check.channel.SupportedModels, \",\") != strings.Join(check.models, \",\") {\n\t\t\tt.Fatalf(\"route %s models=%v, want %v\", check.name, check.channel.SupportedModels, check.models)\n\t\t}\n\t}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesRenamesAndSyncsCredentials(t *testing.T) {\n\tchannels := []config.AccountChannel{\n\t\t{Kind: \"messages\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-messages\", Name: \"old-claude\",\n\t\t\tServiceType: \"claude\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t\t{Kind: \"responses\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-responses\", Name: \"old-codex\",\n\t\t\tServiceType: \"responses\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t}\n\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\tName: \"renamed\", APIKeys: []string{\"sk-old\", \"sk-new\"},\n\t}, channels)\n\tif err != nil || status != http.StatusOK {\n\t\tt.Fatalf(\"status=%d err=%v\", status, err)\n\t}\n\tif len(updates) != 2 || updates[0].Name != \"renamed-claude\" || updates[1].Name != \"renamed-codex\" {\n\t\tt.Fatalf(\"route names=%+v\", updates)\n\t}\n\tfor _, update := range updates {\n\t\tif len(update.APIKeyConfig) != 2 || update.APIKeyConfig[1].BaseURL != \"https://example.com\" {\n\t\t\tt.Fatalf(\"route credentials=%+v\", update.APIKeyConfig)\n\t\t}\n\t\tif update.APIKeyConfig[1].CredentialUID == \"\" {\n\t\t\tt.Fatalf(\"new credential missing stable uid: %+v\", update.APIKeyConfig[1])\n\t\t}\n\t}\n}\n\nfunc TestProviderRouteNameAndPrimaryResult(t *testing.T) {\n\tbase := \"mimo-test\"\n\ttests := []struct {\n\t\troute config.ProviderRoute\n\t\twant string\n\t}{\n\t\t{route: config.ProviderRoute{ChannelKind: \"messages\"}, want: \"mimo-test-claude\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"chat\"}, want: \"mimo-test-chat\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"responses\"}, want: \"mimo-test-codex\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"gemini\"}, want: \"mimo-test-gemini\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := providerRouteName(base, tt.route, true); got != tt.want {\n\t\t\tt.Fatalf(\"providerRouteName(%s)=%q, want %q\", tt.route.ChannelKind, got, tt.want)\n\t\t}\n\t}\n\tif got := providerRouteName(base, config.ProviderRoute{ChannelKind: \"messages\"}, false); got != base {\n\t\tt.Fatalf(\"single route name=%q, want %q\", got, base)\n\t}\n\n\tresults := []AutoAddChannelResult{\n\t\t{ChannelKind: \"messages\", ChannelUID: \"ch_messages\", Index: 1},\n\t\t{ChannelKind: \"chat\", ChannelUID: \"ch_chat\", Index: 2},\n\t\t{ChannelKind: \"responses\", ChannelUID: \"ch_responses\", Index: 3},\n\t}\n\tprimary := primaryAutoAddResult(results, \"chat\")\n\tif primary.ChannelUID != \"ch_chat\" || primary.Index != 2 {\n\t\tt.Fatalf(\"primary=%+v, want chat result\", primary)\n\t}\n}\n\nfunc TestManagedAccountUIDForProviderUsesExistingAccount(t *testing.T) {\n\tcfg := config.Config{ManagedAccounts: []config.ManagedAccountConfig{\n\t\t{AccountUID: \"acct-old\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-new\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-deepseek\", ProviderID: \"deepseek\"},\n\t}}\n\tif got := managedAccountUIDForProvider(cfg, \"mimo\"); got != \"acct-new\" {\n\t\tt.Fatalf(\"mimo existing account = %q, want acct-new\", got)\n\t}\n\tif got := managedAccountUIDForProvider(cfg, \"unknown\"); got != \"\" {\n\t\tt.Fatalf(\"unknown provider 不应命中账号: %q\", got)\n\t}\n}\n\nfunc TestListManagedAccountsDoesNotExposeVolcengineSecret(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-secret\",\"volcengineAccessKey\":{\"accessKeyId\":\"AKIDEXAMPLE\",\"secretAccessKey\":\"SECRET_MUST_NOT_LEAK\",\"plan\":\"agent_plan\",\"planTier\":\"Large\",\"planStatus\":\"Running\"}}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"SECRET_MUST_NOT_LEAK\") || strings.Contains(body, \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", body)\n\t}\n\tfor _, expected := range []string{`\"hasVolcengineAccessKey\":true`, `\"volcenginePlan\":\"agent_plan\"`, `\"volcenginePlanTier\":\"Large\"`} {\n\t\tif !strings.Contains(body, expected) {\n\t\t\tt.Fatalf(\"响应缺少 %s: %s\", expected, body)\n\t\t}\n\t}\n}\n\nfunc TestSetVolcengineAccessKeyDetectsPlanBeforePersisting(t *testing.T) {\n\tcontrolPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Query().Get(\"Action\") != \"GetPersonalPlan\" {\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tvar body struct{ Plan string }\n\t\t_ = json.NewDecoder(r.Body).Decode(&body)\n\t\tif body.Plan == \"CodingPlan\" {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t_, _ = w.Write([]byte(`{\"ResponseMetadata\":{\"Error\":{\"Code\":\"ResourceNotFound.Plan\",\"Message\":\"not found\"}}}`))\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\"Result\":{\"PlanType\":\"Large\",\"Status\":\"Running\"}}`))\n\t}))\n\tdefer controlPlane.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-inference\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\trunner := NewAutoDiscoveryRunner(nil, nil)\n\trunner.client = controlPlane.Client()\n\trunner.volcengineControlPlaneEndpoint = controlPlane.URL\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, Runner: runner})\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_volc/credentials/cred_volc/volcengine-access-key\", bytes.NewBufferString(`{\"accessKeyId\":\"AKID\",\"secretAccessKey\":\"SECRET\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"plan\":\"agent_plan\"`) {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_volc\", \"cred_volc\")\n\tif !ok || credential.VolcengineAccessKey == nil || credential.VolcengineAccessKey.SecretAccessKey != \"SECRET\" || credential.VolcengineAccessKey.Plan != \"agent_plan\" {\n\t\tt.Fatalf(\"凭证未正确持久化: %+v\", credential)\n\t}\n}\n\nfunc TestSetMiMoConsoleCookieRequiresConfirmationBeforeAdoptingKey(t *testing.T) {\n\tconst oldKey = \"tp-old-1234567890123456789012345678901234567890\"\n\tconst cookieKey = \"tp-new-1234567890123456789012345678901234567890\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != \"api-platform_serviceToken=session; userId=42\" {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase \"/api/v1/userProfile\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"userId\":\"42\"}}`))\n\t\tcase \"/api/v1/tokenPlan/detail\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"planCode\":\"max\",\"planName\":\"Max\",\"currentPeriodEnd\":\"2026-07-29 23:59:59\"}}`))\n\t\tcase \"/api/v1/tokenPlan/usage\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"monthUsage\":{\"percent\":0.25,\"items\":[{\"used\":25,\"limit\":100}]},\"usage\":{\"percent\":0.5,\"items\":[{\"used\":50,\"limit\":100}]}}}`))\n\t\tcase \"/api/v1/tokenPlan/apiKey/raw\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":\"` + cookieKey + `\"}`))\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}\n\t}))\n\tdefer console.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"credentials\":[{\"credentialUid\":\"cred_mimo\",\"apiKey\":\"` + oldKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_mimo\",\"channelUid\":\"ch_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_mimo\",\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\tdeps := &AutoManagedDeps{CfgManager: manager, MiMoConsoleClient: &MiMoConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL}}\n\trouter := setupAutoManagedRouter(deps)\n\tbody := `{\"cookie\":\"api-platform_serviceToken=session; userId=42\"}`\n\trequest := func(payload string) *httptest.ResponseRecorder {\n\t\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_mimo/credentials/cred_mimo/mimo-console-cookie\", bytes.NewBufferString(payload))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tw := httptest.NewRecorder()\n\t\trouter.ServeHTTP(w, req)\n\t\treturn w\n\t}\n\tw := request(body)\n\tif w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), `\"code\":\"mimo_cookie_key_mismatch\"`) {\n\t\tt.Fatalf(\"未要求确认: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ := manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != oldKey || credential.MiMoConsole != nil {\n\t\tt.Fatalf(\"确认前不应修改配置: %+v\", credential)\n\t}\n\tw = request(`{\"cookie\":\"api-platform_serviceToken=session; userId=42\",\"adoptCookieKey\":true}`)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"keyAdopted\":true`) {\n\t\tt.Fatalf(\"确认采用失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ = manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != cookieKey || credential.MiMoConsole == nil {\n\t\tt.Fatalf(\"确认后未采用 Cookie Key: %+v\", credential)\n\t}\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif strings.Contains(list.Body.String(), \"api-platform_serviceToken\") || strings.Contains(list.Body.String(), cookieKey) {\n\t\tt.Fatalf(\"账号列表泄漏 Cookie 或原始 Key: %s\", list.Body.String())\n\t}\n}\n\nfunc TestProviderAutoAddReusesExistingAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(`{\"providerId\":\"mimo\",\"apiKeys\":[\"sk-existing\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"追加已有 provider key status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 4 {\n\t\tt.Fatalf(\"旧账号应自动补齐模板中的协议渠道: %+v\", channels)\n\t}\n\tkinds := make(map[string]bool, len(channels))\n\tfor _, channel := range channels {\n\t\tkinds[channel.Kind] = true\n\t}\n\tfor _, kind := range []string{\"messages\", \"chat\", \"responses\", \"gemini\"} {\n\t\tif !kinds[kind] {\n\t\t\tt.Fatalf(\"旧账号缺少 %s route: %+v\", kind, channels)\n\t\t}\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.AccountUID != \"acct_test\" {\n\t\tt.Fatalf(\"响应未返回已有账号: body=%s err=%v\", w.Body.String(), err)\n\t}\n\tif len(response.Channels) != 4 {\n\t\tt.Fatalf(\"响应未返回补齐后的全部渠道: %+v\", response.Channels)\n\t}\n}\n\nfunc TestMissingProviderAccountRoutes(t *testing.T) {\n\ttmpl, ok := config.GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"缺少 deepseek provider 模板\")\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{ServiceType: \"claude\"},\n\t}}\n\tmissing := missingProviderAccountRoutes(tmpl, existing)\n\tif len(missing) != 2 || missing[0].ChannelKind != \"chat\" || missing[1].ChannelKind != \"responses\" {\n\t\tt.Fatalf(\"missing routes = %+v\", missing)\n\t}\n\tconfigs, baseURLs, err := bindProviderRouteKeys(tmpl, missing[0], []string{\"sk-existing\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(configs) != 1 || configs[0].BaseURL != \"https://api.deepseek.com\" || len(baseURLs) != 1 {\n\t\tt.Fatalf(\"route 绑定结果不正确: configs=%+v baseURLs=%v\", configs, baseURLs)\n\t}\n}\n\nfunc TestBindProviderRouteKeysPreservesExistingEndpointAffinity(t *testing.T) {\n\ttests := []struct {\n\t\tproviderID string\n\t\tapiKey string\n\t\texistingBaseURL string\n\t\ttargetKind string\n\t\twantBaseURL string\n\t}{\n\t\t{\n\t\t\tproviderID: \"mimo\", apiKey: \"tp-existing\",\n\t\t\texistingBaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\t\ttargetKind: \"chat\", wantBaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\",\n\t\t},\n\t\t{\n\t\t\tproviderID: \"volcengine\", apiKey: \"ark-existing\",\n\t\t\texistingBaseURL: \"https://ark.cn-beijing.volces.com/api/coding\",\n\t\t\ttargetKind: \"responses\", wantBaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.providerID, func(t *testing.T) {\n\t\t\ttmpl, ok := config.GetProviderTemplate(tt.providerID)\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"缺少 provider 模板: %s\", tt.providerID)\n\t\t\t}\n\t\t\texisting := []config.AccountChannel{{\n\t\t\t\tKind: \"messages\",\n\t\t\t\tUpstream: config.UpstreamConfig{\n\t\t\t\t\tProviderID: tt.providerID, ServiceType: \"claude\", APIKeys: []string{tt.apiKey},\n\t\t\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: tt.apiKey, BaseURL: tt.existingBaseURL}},\n\t\t\t\t},\n\t\t\t}}\n\t\t\tvar target config.ProviderRoute\n\t\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\t\tif route.ChannelKind == tt.targetKind {\n\t\t\t\t\ttarget = route\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tconfigs, _, err := bindProviderRouteKeysWithAffinities(\n\t\t\t\ttmpl, target, []string{tt.apiKey}, providerKeyCandidateAffinities(tmpl, existing),\n\t\t\t)\n\t\t\tif err != nil || len(configs) != 1 || configs[0].BaseURL != tt.wantBaseURL {\n\t\t\t\tt.Fatalf(\"亲和绑定错误: configs=%+v err=%v want=%s\", configs, err, tt.wantBaseURL)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPlanProviderAccountRouteAdditionsRejectsNewKeyBeforeMutation(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t}))\n\tdefer server.Close()\n\n\ttmpl := &config.ProviderTemplate{\n\t\tProviderID: \"test-provider\",\n\t\tRoutes: []config.ProviderRoute{\n\t\t\t{ChannelKind: \"messages\", ServiceType: \"claude\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/anthropic\"}}},\n\t\t\t{ChannelKind: \"chat\", ServiceType: \"openai\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/v1\"}}},\n\t\t},\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct_test\", ChannelUID: \"ch_messages\", Name: \"test-provider\",\n\t\t\tProviderID: \"test-provider\", ServiceType: \"claude\", AutoManaged: true, APIKeys: []string{\"sk-existing\"},\n\t\t},\n\t}}\n\tadditions, status, err := planProviderAccountRouteAdditions(\n\t\tt.Context(), config.Config{}, tmpl, \"acct_test\", []string{\"sk-existing\", \"sk-invalid\"}, existing,\n\t)\n\tif err == nil || status != http.StatusBadRequest || len(additions) != 0 {\n\t\tt.Fatalf(\"无效新 Key 应在生成新增 route 前失败: status=%d additions=%+v err=%v\", status, additions, err)\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}, "5743084bf3f7dc6abffbf9b41b81ce80f22d07fa": {"t": 1785759356.0491586, "n": 33533, "h": "6151b46796395d8a", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestListAccountsMasksCredentials(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-secret-value\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil)\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"sk-secret-value\") {\n\t\tt.Fatalf(\"账号列表泄露明文 Key: %s\", body)\n\t}\n\tif !strings.Contains(body, \"cred_test\") || !strings.Contains(body, \"keyMask\") {\n\t\tt.Fatalf(\"账号列表缺少凭证掩码信息: %s\", body)\n\t}\n}\n\nfunc TestPatchAccountCredentialsRemovesByUID(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"sk-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"sk-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"baseUrls\":[\"https://api.xiaomimimo.com/anthropic\"],\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPatch, \"/api/accounts/acct_test/credentials\", bytes.NewBufferString(`{\"removeCredentialUids\":[\"cred_b\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PATCH credentials status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 1 || len(channels[0].Upstream.APIKeys) != 1 || channels[0].Upstream.APIKeys[0] != \"sk-a\" {\n\t\tt.Fatalf(\"按 credentialUid 删除失败: %+v\", channels)\n\t}\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestInferAutoAddProviderID(t *testing.T) {\n\tzhipuKey := \"0123456789abcdef0123456789abcdef.ABCDEFGHIJKLMNO1\"\n\ttests := []struct {\n\t\tname string\n\t\tbaseURLs []string\n\t\tapiKeys []string\n\t\twant string\n\t}{\n\t\t{name: \"官方 Claude URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"官方 OpenAI URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4/\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"两个官方协议 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\", \"https://open.bigmodel.cn/api/paas/v4\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"仅智谱 Key\", apiKeys: []string{zhipuKey}, want: \"glm\"},\n\t\t{name: \"第三方 URL 不按 Key 提升\", baseURLs: []string{\"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"混合官方与第三方 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4\", \"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"共享 sk Key 无法推断\", apiKeys: []string{\"sk-abcdefghijklmnopqrstuvwxyz123456\"}},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := inferAutoAddProviderID(tt.baseURLs, tt.apiKeys); got != tt.want {\n\t\t\t\tt.Fatalf(\"inferAutoAddProviderID() = %q, want %q\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestApplyProviderUpstreamDefaults(t *testing.T) {\n\tglmChat := config.UpstreamConfig{ServiceType: \"openai\"}\n\tapplyProviderUpstreamDefaults(\"glm\", &glmChat)\n\tif glmChat.ReasoningParamStyle != \"reasoning_effort\" || !glmChat.PassbackReasoningContent {\n\t\tt.Fatalf(\"GLM OpenAI 默认兼容参数未补齐: %+v\", glmChat)\n\t}\n\n\tglmClaude := config.UpstreamConfig{ServiceType: \"claude\"}\n\tapplyProviderUpstreamDefaults(\"glm\", &glmClaude)\n\tif glmClaude.ReasoningParamStyle != \"\" || glmClaude.PassbackReasoningContent {\n\t\tt.Fatalf(\"GLM Claude 原生 route 不应注入 OpenAI 参数: %+v\", glmClaude)\n\t}\n\n\tcustom := config.UpstreamConfig{ServiceType: \"openai\"}\n\tapplyProviderUpstreamDefaults(\"\", &custom)\n\tif custom.ReasoningParamStyle != \"\" || custom.PassbackReasoningContent {\n\t\tt.Fatalf(\"自定义渠道不应应用 GLM 默认值: %+v\", custom)\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCustomAutoAddResponseIncludesActualRoute(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"fastaitoken-com-test\",\"baseUrls\":[\"https://example.com\"],\"apiKeys\":[\"sk-test\"]}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 {\n\t\tt.Fatalf(\"channels=%+v, want one actual route\", response.Channels)\n\t}\n\troute := response.Channels[0]\n\tif route.ChannelKind != \"responses\" || route.ServiceType != \"responses\" || route.ChannelUID != response.ChannelUID {\n\t\tt.Fatalf(\"route=%+v response=%+v\", route, response)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.ResponsesUpstream) != 1 || len(cfg.Upstream) != 0 {\n\t\tt.Fatalf(\"custom route persisted in wrong channel: responses=%d messages=%d\", len(cfg.ResponsesUpstream), len(cfg.Upstream))\n\t}\n}\n\nfunc TestCustomAutoAddCreatesAllDetectedRoutesWithProtocolModels(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"fastaitoken-com-test\",\n \"baseUrls\":[\"https://example.com/keys\"],\n \"apiKeys\":[\"sk-test\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"shared\",\"messages-only\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"shared\",\"responses-only\"]},\n {\"channelKind\":\"chat\",\"supportedModels\":[\"shared\",\"chat-only\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 3 {\n\t\tt.Fatalf(\"channels=%+v, want three detected routes\", response.Channels)\n\t}\n\tfor _, route := range response.Channels {\n\t\tif route.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route account=%q, want %q\", route.AccountUID, response.AccountUID)\n\t\t}\n\t}\n\tif response.Channels[1].ChannelKind != \"responses\" || response.ChannelUID != response.Channels[1].ChannelUID {\n\t\tt.Fatalf(\"请求协议应作为主路由: response=%+v\", response)\n\t}\n\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 || len(cfg.ChatUpstream) != 1 || len(cfg.GeminiUpstream) != 0 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d chat=%d gemini=%d\",\n\t\t\tlen(cfg.Upstream), len(cfg.ResponsesUpstream), len(cfg.ChatUpstream), len(cfg.GeminiUpstream))\n\t}\n\tchecks := []struct {\n\t\tchannel config.UpstreamConfig\n\t\tname string\n\t\tmodels []string\n\t}{\n\t\t{cfg.Upstream[0], \"fastaitoken-com-test-claude\", []string{\"shared\", \"messages-only\"}},\n\t\t{cfg.ResponsesUpstream[0], \"fastaitoken-com-test-codex\", []string{\"shared\", \"responses-only\"}},\n\t\t{cfg.ChatUpstream[0], \"fastaitoken-com-test-chat\", []string{\"shared\", \"chat-only\"}},\n\t}\n\tfor _, check := range checks {\n\t\tif check.channel.Name != check.name || check.channel.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route identity=%+v, want name=%q account=%q\", check.channel, check.name, response.AccountUID)\n\t\t}\n\t\tif strings.Join(check.channel.SupportedModels, \",\") != strings.Join(check.models, \",\") {\n\t\t\tt.Fatalf(\"route %s models=%v, want %v\", check.name, check.channel.SupportedModels, check.models)\n\t\t}\n\t}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesRenamesAndSyncsCredentials(t *testing.T) {\n\tchannels := []config.AccountChannel{\n\t\t{Kind: \"messages\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-messages\", Name: \"old-claude\",\n\t\t\tServiceType: \"claude\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t\t{Kind: \"responses\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-responses\", Name: \"old-codex\",\n\t\t\tServiceType: \"responses\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t}\n\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\tName: \"renamed\", APIKeys: []string{\"sk-old\", \"sk-new\"},\n\t}, channels)\n\tif err != nil || status != http.StatusOK {\n\t\tt.Fatalf(\"status=%d err=%v\", status, err)\n\t}\n\tif len(updates) != 2 || updates[0].Name != \"renamed-claude\" || updates[1].Name != \"renamed-codex\" {\n\t\tt.Fatalf(\"route names=%+v\", updates)\n\t}\n\tfor _, update := range updates {\n\t\tif len(update.APIKeyConfig) != 2 || update.APIKeyConfig[1].BaseURL != \"https://example.com\" {\n\t\t\tt.Fatalf(\"route credentials=%+v\", update.APIKeyConfig)\n\t\t}\n\t\tif update.APIKeyConfig[1].CredentialUID == \"\" {\n\t\t\tt.Fatalf(\"new credential missing stable uid: %+v\", update.APIKeyConfig[1])\n\t\t}\n\t}\n}\n\nfunc TestProviderRouteNameAndPrimaryResult(t *testing.T) {\n\tbase := \"mimo-test\"\n\ttests := []struct {\n\t\troute config.ProviderRoute\n\t\twant string\n\t}{\n\t\t{route: config.ProviderRoute{ChannelKind: \"messages\"}, want: \"mimo-test-claude\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"chat\"}, want: \"mimo-test-chat\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"responses\"}, want: \"mimo-test-codex\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"gemini\"}, want: \"mimo-test-gemini\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := providerRouteName(base, tt.route, true); got != tt.want {\n\t\t\tt.Fatalf(\"providerRouteName(%s)=%q, want %q\", tt.route.ChannelKind, got, tt.want)\n\t\t}\n\t}\n\tif got := providerRouteName(base, config.ProviderRoute{ChannelKind: \"messages\"}, false); got != base {\n\t\tt.Fatalf(\"single route name=%q, want %q\", got, base)\n\t}\n\n\tresults := []AutoAddChannelResult{\n\t\t{ChannelKind: \"messages\", ChannelUID: \"ch_messages\", Index: 1},\n\t\t{ChannelKind: \"chat\", ChannelUID: \"ch_chat\", Index: 2},\n\t\t{ChannelKind: \"responses\", ChannelUID: \"ch_responses\", Index: 3},\n\t}\n\tprimary := primaryAutoAddResult(results, \"chat\")\n\tif primary.ChannelUID != \"ch_chat\" || primary.Index != 2 {\n\t\tt.Fatalf(\"primary=%+v, want chat result\", primary)\n\t}\n}\n\nfunc TestManagedAccountUIDForProviderUsesExistingAccount(t *testing.T) {\n\tcfg := config.Config{ManagedAccounts: []config.ManagedAccountConfig{\n\t\t{AccountUID: \"acct-old\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-new\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-deepseek\", ProviderID: \"deepseek\"},\n\t}}\n\tif got := managedAccountUIDForProvider(cfg, \"mimo\"); got != \"acct-new\" {\n\t\tt.Fatalf(\"mimo existing account = %q, want acct-new\", got)\n\t}\n\tif got := managedAccountUIDForProvider(cfg, \"unknown\"); got != \"\" {\n\t\tt.Fatalf(\"unknown provider 不应命中账号: %q\", got)\n\t}\n}\n\nfunc TestListManagedAccountsDoesNotExposeVolcengineSecret(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-secret\",\"volcengineAccessKey\":{\"accessKeyId\":\"AKIDEXAMPLE\",\"secretAccessKey\":\"SECRET_MUST_NOT_LEAK\",\"plan\":\"agent_plan\",\"planTier\":\"Large\",\"planStatus\":\"Running\"}}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"SECRET_MUST_NOT_LEAK\") || strings.Contains(body, \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", body)\n\t}\n\tfor _, expected := range []string{`\"hasVolcengineAccessKey\":true`, `\"volcenginePlan\":\"agent_plan\"`, `\"volcenginePlanTier\":\"Large\"`} {\n\t\tif !strings.Contains(body, expected) {\n\t\t\tt.Fatalf(\"响应缺少 %s: %s\", expected, body)\n\t\t}\n\t}\n}\n\nfunc TestSetVolcengineAccessKeyDetectsPlanBeforePersisting(t *testing.T) {\n\tcontrolPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Query().Get(\"Action\") != \"GetPersonalPlan\" {\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tvar body struct{ Plan string }\n\t\t_ = json.NewDecoder(r.Body).Decode(&body)\n\t\tif body.Plan == \"CodingPlan\" {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t_, _ = w.Write([]byte(`{\"ResponseMetadata\":{\"Error\":{\"Code\":\"ResourceNotFound.Plan\",\"Message\":\"not found\"}}}`))\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\"Result\":{\"PlanType\":\"Large\",\"Status\":\"Running\"}}`))\n\t}))\n\tdefer controlPlane.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-inference\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\trunner := NewAutoDiscoveryRunner(nil, nil)\n\trunner.client = controlPlane.Client()\n\trunner.volcengineControlPlaneEndpoint = controlPlane.URL\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, Runner: runner})\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_volc/credentials/cred_volc/volcengine-access-key\", bytes.NewBufferString(`{\"accessKeyId\":\"AKID\",\"secretAccessKey\":\"SECRET\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"plan\":\"agent_plan\"`) {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_volc\", \"cred_volc\")\n\tif !ok || credential.VolcengineAccessKey == nil || credential.VolcengineAccessKey.SecretAccessKey != \"SECRET\" || credential.VolcengineAccessKey.Plan != \"agent_plan\" {\n\t\tt.Fatalf(\"凭证未正确持久化: %+v\", credential)\n\t}\n}\n\nfunc TestSetMiMoConsoleCookieRequiresConfirmationBeforeAdoptingKey(t *testing.T) {\n\tconst oldKey = \"tp-old-1234567890123456789012345678901234567890\"\n\tconst cookieKey = \"tp-new-1234567890123456789012345678901234567890\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != \"api-platform_serviceToken=session; userId=42\" {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase \"/api/v1/userProfile\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"userId\":\"42\"}}`))\n\t\tcase \"/api/v1/tokenPlan/detail\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"planCode\":\"max\",\"planName\":\"Max\",\"currentPeriodEnd\":\"2026-07-29 23:59:59\"}}`))\n\t\tcase \"/api/v1/tokenPlan/usage\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"monthUsage\":{\"percent\":0.25,\"items\":[{\"used\":25,\"limit\":100}]},\"usage\":{\"percent\":0.5,\"items\":[{\"used\":50,\"limit\":100}]}}}`))\n\t\tcase \"/api/v1/tokenPlan/apiKey/raw\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":\"` + cookieKey + `\"}`))\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}\n\t}))\n\tdefer console.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"credentials\":[{\"credentialUid\":\"cred_mimo\",\"apiKey\":\"` + oldKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_mimo\",\"channelUid\":\"ch_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_mimo\",\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\tdeps := &AutoManagedDeps{CfgManager: manager, MiMoConsoleClient: &MiMoConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL}}\n\trouter := setupAutoManagedRouter(deps)\n\tbody := `{\"cookie\":\"api-platform_serviceToken=session; userId=42\"}`\n\trequest := func(payload string) *httptest.ResponseRecorder {\n\t\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_mimo/credentials/cred_mimo/mimo-console-cookie\", bytes.NewBufferString(payload))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tw := httptest.NewRecorder()\n\t\trouter.ServeHTTP(w, req)\n\t\treturn w\n\t}\n\tw := request(body)\n\tif w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), `\"code\":\"mimo_cookie_key_mismatch\"`) {\n\t\tt.Fatalf(\"未要求确认: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ := manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != oldKey || credential.MiMoConsole != nil {\n\t\tt.Fatalf(\"确认前不应修改配置: %+v\", credential)\n\t}\n\tw = request(`{\"cookie\":\"api-platform_serviceToken=session; userId=42\",\"adoptCookieKey\":true}`)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"keyAdopted\":true`) {\n\t\tt.Fatalf(\"确认采用失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ = manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != cookieKey || credential.MiMoConsole == nil {\n\t\tt.Fatalf(\"确认后未采用 Cookie Key: %+v\", credential)\n\t}\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif strings.Contains(list.Body.String(), \"api-platform_serviceToken\") || strings.Contains(list.Body.String(), cookieKey) {\n\t\tt.Fatalf(\"账号列表泄漏 Cookie 或原始 Key: %s\", list.Body.String())\n\t}\n}\n\nfunc TestProviderAutoAddReusesExistingAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(`{\"providerId\":\"mimo\",\"apiKeys\":[\"sk-existing\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"追加已有 provider key status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 4 {\n\t\tt.Fatalf(\"旧账号应自动补齐模板中的协议渠道: %+v\", channels)\n\t}\n\tkinds := make(map[string]bool, len(channels))\n\tfor _, channel := range channels {\n\t\tkinds[channel.Kind] = true\n\t}\n\tfor _, kind := range []string{\"messages\", \"chat\", \"responses\", \"gemini\"} {\n\t\tif !kinds[kind] {\n\t\t\tt.Fatalf(\"旧账号缺少 %s route: %+v\", kind, channels)\n\t\t}\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.AccountUID != \"acct_test\" {\n\t\tt.Fatalf(\"响应未返回已有账号: body=%s err=%v\", w.Body.String(), err)\n\t}\n\tif len(response.Channels) != 4 {\n\t\tt.Fatalf(\"响应未返回补齐后的全部渠道: %+v\", response.Channels)\n\t}\n}\n\nfunc TestMissingProviderAccountRoutes(t *testing.T) {\n\ttmpl, ok := config.GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"缺少 deepseek provider 模板\")\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{ServiceType: \"claude\"},\n\t}}\n\tmissing := missingProviderAccountRoutes(tmpl, existing)\n\tif len(missing) != 2 || missing[0].ChannelKind != \"chat\" || missing[1].ChannelKind != \"responses\" {\n\t\tt.Fatalf(\"missing routes = %+v\", missing)\n\t}\n\tconfigs, baseURLs, err := bindProviderRouteKeys(tmpl, missing[0], []string{\"sk-existing\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(configs) != 1 || configs[0].BaseURL != \"https://api.deepseek.com\" || len(baseURLs) != 1 {\n\t\tt.Fatalf(\"route 绑定结果不正确: configs=%+v baseURLs=%v\", configs, baseURLs)\n\t}\n}\n\nfunc TestBindProviderRouteKeysPreservesExistingEndpointAffinity(t *testing.T) {\n\ttests := []struct {\n\t\tproviderID string\n\t\tapiKey string\n\t\texistingBaseURL string\n\t\ttargetKind string\n\t\twantBaseURL string\n\t}{\n\t\t{\n\t\t\tproviderID: \"mimo\", apiKey: \"tp-existing\",\n\t\t\texistingBaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\t\ttargetKind: \"chat\", wantBaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\",\n\t\t},\n\t\t{\n\t\t\tproviderID: \"volcengine\", apiKey: \"ark-existing\",\n\t\t\texistingBaseURL: \"https://ark.cn-beijing.volces.com/api/coding\",\n\t\t\ttargetKind: \"responses\", wantBaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.providerID, func(t *testing.T) {\n\t\t\ttmpl, ok := config.GetProviderTemplate(tt.providerID)\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"缺少 provider 模板: %s\", tt.providerID)\n\t\t\t}\n\t\t\texisting := []config.AccountChannel{{\n\t\t\t\tKind: \"messages\",\n\t\t\t\tUpstream: config.UpstreamConfig{\n\t\t\t\t\tProviderID: tt.providerID, ServiceType: \"claude\", APIKeys: []string{tt.apiKey},\n\t\t\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: tt.apiKey, BaseURL: tt.existingBaseURL}},\n\t\t\t\t},\n\t\t\t}}\n\t\t\tvar target config.ProviderRoute\n\t\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\t\tif route.ChannelKind == tt.targetKind {\n\t\t\t\t\ttarget = route\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tconfigs, _, err := bindProviderRouteKeysWithAffinities(\n\t\t\t\ttmpl, target, []string{tt.apiKey}, providerKeyCandidateAffinities(tmpl, existing),\n\t\t\t)\n\t\t\tif err != nil || len(configs) != 1 || configs[0].BaseURL != tt.wantBaseURL {\n\t\t\t\tt.Fatalf(\"亲和绑定错误: configs=%+v err=%v want=%s\", configs, err, tt.wantBaseURL)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPlanProviderAccountRouteAdditionsRejectsNewKeyBeforeMutation(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t}))\n\tdefer server.Close()\n\n\ttmpl := &config.ProviderTemplate{\n\t\tProviderID: \"test-provider\",\n\t\tRoutes: []config.ProviderRoute{\n\t\t\t{ChannelKind: \"messages\", ServiceType: \"claude\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/anthropic\"}}},\n\t\t\t{ChannelKind: \"chat\", ServiceType: \"openai\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/v1\"}}},\n\t\t},\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct_test\", ChannelUID: \"ch_messages\", Name: \"test-provider\",\n\t\t\tProviderID: \"test-provider\", ServiceType: \"claude\", AutoManaged: true, APIKeys: []string{\"sk-existing\"},\n\t\t},\n\t}}\n\tadditions, status, err := planProviderAccountRouteAdditions(\n\t\tt.Context(), config.Config{}, tmpl, \"acct_test\", []string{\"sk-existing\", \"sk-invalid\"}, existing,\n\t)\n\tif err == nil || status != http.StatusBadRequest || len(additions) != 0 {\n\t\tt.Fatalf(\"无效新 Key 应在生成新增 route 前失败: status=%d additions=%+v err=%v\", status, additions, err)\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}, "b4fabcb69896d68027efca131ce606a40985e43c": {"t": 1785759357.6377583, "n": 31468, "h": "f7d601072eb63c09", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestListAccountsMasksCredentials(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-secret-value\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil)\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"sk-secret-value\") {\n\t\tt.Fatalf(\"账号列表泄露明文 Key: %s\", body)\n\t}\n\tif !strings.Contains(body, \"cred_test\") || !strings.Contains(body, \"keyMask\") {\n\t\tt.Fatalf(\"账号列表缺少凭证掩码信息: %s\", body)\n\t}\n}\n\nfunc TestPatchAccountCredentialsRemovesByUID(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"sk-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"sk-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"baseUrls\":[\"https://api.xiaomimimo.com/anthropic\"],\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPatch, \"/api/accounts/acct_test/credentials\", bytes.NewBufferString(`{\"removeCredentialUids\":[\"cred_b\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PATCH credentials status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 1 || len(channels[0].Upstream.APIKeys) != 1 || channels[0].Upstream.APIKeys[0] != \"sk-a\" {\n\t\tt.Fatalf(\"按 credentialUid 删除失败: %+v\", channels)\n\t}\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCustomAutoAddResponseIncludesActualRoute(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"fastaitoken-com-test\",\"baseUrls\":[\"https://example.com\"],\"apiKeys\":[\"sk-test\"]}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 {\n\t\tt.Fatalf(\"channels=%+v, want one actual route\", response.Channels)\n\t}\n\troute := response.Channels[0]\n\tif route.ChannelKind != \"responses\" || route.ServiceType != \"responses\" || route.ChannelUID != response.ChannelUID {\n\t\tt.Fatalf(\"route=%+v response=%+v\", route, response)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.ResponsesUpstream) != 1 || len(cfg.Upstream) != 0 {\n\t\tt.Fatalf(\"custom route persisted in wrong channel: responses=%d messages=%d\", len(cfg.ResponsesUpstream), len(cfg.Upstream))\n\t}\n}\n\nfunc TestCustomAutoAddCreatesAllDetectedRoutesWithProtocolModels(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"fastaitoken-com-test\",\n \"baseUrls\":[\"https://example.com/keys\"],\n \"apiKeys\":[\"sk-test\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"shared\",\"messages-only\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"shared\",\"responses-only\"]},\n {\"channelKind\":\"chat\",\"supportedModels\":[\"shared\",\"chat-only\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 3 {\n\t\tt.Fatalf(\"channels=%+v, want three detected routes\", response.Channels)\n\t}\n\tfor _, route := range response.Channels {\n\t\tif route.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route account=%q, want %q\", route.AccountUID, response.AccountUID)\n\t\t}\n\t}\n\tif response.Channels[1].ChannelKind != \"responses\" || response.ChannelUID != response.Channels[1].ChannelUID {\n\t\tt.Fatalf(\"请求协议应作为主路由: response=%+v\", response)\n\t}\n\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 || len(cfg.ChatUpstream) != 1 || len(cfg.GeminiUpstream) != 0 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d chat=%d gemini=%d\",\n\t\t\tlen(cfg.Upstream), len(cfg.ResponsesUpstream), len(cfg.ChatUpstream), len(cfg.GeminiUpstream))\n\t}\n\tchecks := []struct {\n\t\tchannel config.UpstreamConfig\n\t\tname string\n\t\tmodels []string\n\t}{\n\t\t{cfg.Upstream[0], \"fastaitoken-com-test-claude\", []string{\"shared\", \"messages-only\"}},\n\t\t{cfg.ResponsesUpstream[0], \"fastaitoken-com-test-codex\", []string{\"shared\", \"responses-only\"}},\n\t\t{cfg.ChatUpstream[0], \"fastaitoken-com-test-chat\", []string{\"shared\", \"chat-only\"}},\n\t}\n\tfor _, check := range checks {\n\t\tif check.channel.Name != check.name || check.channel.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route identity=%+v, want name=%q account=%q\", check.channel, check.name, response.AccountUID)\n\t\t}\n\t\tif strings.Join(check.channel.SupportedModels, \",\") != strings.Join(check.models, \",\") {\n\t\t\tt.Fatalf(\"route %s models=%v, want %v\", check.name, check.channel.SupportedModels, check.models)\n\t\t}\n\t}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesRenamesAndSyncsCredentials(t *testing.T) {\n\tchannels := []config.AccountChannel{\n\t\t{Kind: \"messages\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-messages\", Name: \"old-claude\",\n\t\t\tServiceType: \"claude\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t\t{Kind: \"responses\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-responses\", Name: \"old-codex\",\n\t\t\tServiceType: \"responses\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t}\n\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\tName: \"renamed\", APIKeys: []string{\"sk-old\", \"sk-new\"},\n\t}, channels)\n\tif err != nil || status != http.StatusOK {\n\t\tt.Fatalf(\"status=%d err=%v\", status, err)\n\t}\n\tif len(updates) != 2 || updates[0].Name != \"renamed-claude\" || updates[1].Name != \"renamed-codex\" {\n\t\tt.Fatalf(\"route names=%+v\", updates)\n\t}\n\tfor _, update := range updates {\n\t\tif len(update.APIKeyConfig) != 2 || update.APIKeyConfig[1].BaseURL != \"https://example.com\" {\n\t\t\tt.Fatalf(\"route credentials=%+v\", update.APIKeyConfig)\n\t\t}\n\t\tif update.APIKeyConfig[1].CredentialUID == \"\" {\n\t\t\tt.Fatalf(\"new credential missing stable uid: %+v\", update.APIKeyConfig[1])\n\t\t}\n\t}\n}\n\nfunc TestProviderRouteNameAndPrimaryResult(t *testing.T) {\n\tbase := \"mimo-test\"\n\ttests := []struct {\n\t\troute config.ProviderRoute\n\t\twant string\n\t}{\n\t\t{route: config.ProviderRoute{ChannelKind: \"messages\"}, want: \"mimo-test-claude\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"chat\"}, want: \"mimo-test-chat\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"responses\"}, want: \"mimo-test-codex\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"gemini\"}, want: \"mimo-test-gemini\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := providerRouteName(base, tt.route, true); got != tt.want {\n\t\t\tt.Fatalf(\"providerRouteName(%s)=%q, want %q\", tt.route.ChannelKind, got, tt.want)\n\t\t}\n\t}\n\tif got := providerRouteName(base, config.ProviderRoute{ChannelKind: \"messages\"}, false); got != base {\n\t\tt.Fatalf(\"single route name=%q, want %q\", got, base)\n\t}\n\n\tresults := []AutoAddChannelResult{\n\t\t{ChannelKind: \"messages\", ChannelUID: \"ch_messages\", Index: 1},\n\t\t{ChannelKind: \"chat\", ChannelUID: \"ch_chat\", Index: 2},\n\t\t{ChannelKind: \"responses\", ChannelUID: \"ch_responses\", Index: 3},\n\t}\n\tprimary := primaryAutoAddResult(results, \"chat\")\n\tif primary.ChannelUID != \"ch_chat\" || primary.Index != 2 {\n\t\tt.Fatalf(\"primary=%+v, want chat result\", primary)\n\t}\n}\n\nfunc TestManagedAccountUIDForProviderUsesExistingAccount(t *testing.T) {\n\tcfg := config.Config{ManagedAccounts: []config.ManagedAccountConfig{\n\t\t{AccountUID: \"acct-old\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-new\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-deepseek\", ProviderID: \"deepseek\"},\n\t}}\n\tif got := managedAccountUIDForProvider(cfg, \"mimo\"); got != \"acct-new\" {\n\t\tt.Fatalf(\"mimo existing account = %q, want acct-new\", got)\n\t}\n\tif got := managedAccountUIDForProvider(cfg, \"unknown\"); got != \"\" {\n\t\tt.Fatalf(\"unknown provider 不应命中账号: %q\", got)\n\t}\n}\n\nfunc TestListManagedAccountsDoesNotExposeVolcengineSecret(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-secret\",\"volcengineAccessKey\":{\"accessKeyId\":\"AKIDEXAMPLE\",\"secretAccessKey\":\"SECRET_MUST_NOT_LEAK\",\"plan\":\"agent_plan\",\"planTier\":\"Large\",\"planStatus\":\"Running\"}}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"SECRET_MUST_NOT_LEAK\") || strings.Contains(body, \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", body)\n\t}\n\tfor _, expected := range []string{`\"hasVolcengineAccessKey\":true`, `\"volcenginePlan\":\"agent_plan\"`, `\"volcenginePlanTier\":\"Large\"`} {\n\t\tif !strings.Contains(body, expected) {\n\t\t\tt.Fatalf(\"响应缺少 %s: %s\", expected, body)\n\t\t}\n\t}\n}\n\nfunc TestSetVolcengineAccessKeyDetectsPlanBeforePersisting(t *testing.T) {\n\tcontrolPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Query().Get(\"Action\") != \"GetPersonalPlan\" {\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tvar body struct{ Plan string }\n\t\t_ = json.NewDecoder(r.Body).Decode(&body)\n\t\tif body.Plan == \"CodingPlan\" {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t_, _ = w.Write([]byte(`{\"ResponseMetadata\":{\"Error\":{\"Code\":\"ResourceNotFound.Plan\",\"Message\":\"not found\"}}}`))\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\"Result\":{\"PlanType\":\"Large\",\"Status\":\"Running\"}}`))\n\t}))\n\tdefer controlPlane.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-inference\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\trunner := NewAutoDiscoveryRunner(nil, nil)\n\trunner.client = controlPlane.Client()\n\trunner.volcengineControlPlaneEndpoint = controlPlane.URL\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, Runner: runner})\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_volc/credentials/cred_volc/volcengine-access-key\", bytes.NewBufferString(`{\"accessKeyId\":\"AKID\",\"secretAccessKey\":\"SECRET\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"plan\":\"agent_plan\"`) {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_volc\", \"cred_volc\")\n\tif !ok || credential.VolcengineAccessKey == nil || credential.VolcengineAccessKey.SecretAccessKey != \"SECRET\" || credential.VolcengineAccessKey.Plan != \"agent_plan\" {\n\t\tt.Fatalf(\"凭证未正确持久化: %+v\", credential)\n\t}\n}\n\nfunc TestSetMiMoConsoleCookieRequiresConfirmationBeforeAdoptingKey(t *testing.T) {\n\tconst oldKey = \"tp-old-1234567890123456789012345678901234567890\"\n\tconst cookieKey = \"tp-new-1234567890123456789012345678901234567890\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != \"api-platform_serviceToken=session; userId=42\" {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase \"/api/v1/userProfile\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"userId\":\"42\"}}`))\n\t\tcase \"/api/v1/tokenPlan/detail\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"planCode\":\"max\",\"planName\":\"Max\",\"currentPeriodEnd\":\"2026-07-29 23:59:59\"}}`))\n\t\tcase \"/api/v1/tokenPlan/usage\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"monthUsage\":{\"percent\":0.25,\"items\":[{\"used\":25,\"limit\":100}]},\"usage\":{\"percent\":0.5,\"items\":[{\"used\":50,\"limit\":100}]}}}`))\n\t\tcase \"/api/v1/tokenPlan/apiKey/raw\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":\"` + cookieKey + `\"}`))\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}\n\t}))\n\tdefer console.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"credentials\":[{\"credentialUid\":\"cred_mimo\",\"apiKey\":\"` + oldKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_mimo\",\"channelUid\":\"ch_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_mimo\",\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\tdeps := &AutoManagedDeps{CfgManager: manager, MiMoConsoleClient: &MiMoConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL}}\n\trouter := setupAutoManagedRouter(deps)\n\tbody := `{\"cookie\":\"api-platform_serviceToken=session; userId=42\"}`\n\trequest := func(payload string) *httptest.ResponseRecorder {\n\t\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_mimo/credentials/cred_mimo/mimo-console-cookie\", bytes.NewBufferString(payload))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tw := httptest.NewRecorder()\n\t\trouter.ServeHTTP(w, req)\n\t\treturn w\n\t}\n\tw := request(body)\n\tif w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), `\"code\":\"mimo_cookie_key_mismatch\"`) {\n\t\tt.Fatalf(\"未要求确认: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ := manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != oldKey || credential.MiMoConsole != nil {\n\t\tt.Fatalf(\"确认前不应修改配置: %+v\", credential)\n\t}\n\tw = request(`{\"cookie\":\"api-platform_serviceToken=session; userId=42\",\"adoptCookieKey\":true}`)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"keyAdopted\":true`) {\n\t\tt.Fatalf(\"确认采用失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ = manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != cookieKey || credential.MiMoConsole == nil {\n\t\tt.Fatalf(\"确认后未采用 Cookie Key: %+v\", credential)\n\t}\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif strings.Contains(list.Body.String(), \"api-platform_serviceToken\") || strings.Contains(list.Body.String(), cookieKey) {\n\t\tt.Fatalf(\"账号列表泄漏 Cookie 或原始 Key: %s\", list.Body.String())\n\t}\n}\n\nfunc TestProviderAutoAddReusesExistingAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(`{\"providerId\":\"mimo\",\"apiKeys\":[\"sk-existing\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"追加已有 provider key status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 4 {\n\t\tt.Fatalf(\"旧账号应自动补齐模板中的协议渠道: %+v\", channels)\n\t}\n\tkinds := make(map[string]bool, len(channels))\n\tfor _, channel := range channels {\n\t\tkinds[channel.Kind] = true\n\t}\n\tfor _, kind := range []string{\"messages\", \"chat\", \"responses\", \"gemini\"} {\n\t\tif !kinds[kind] {\n\t\t\tt.Fatalf(\"旧账号缺少 %s route: %+v\", kind, channels)\n\t\t}\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.AccountUID != \"acct_test\" {\n\t\tt.Fatalf(\"响应未返回已有账号: body=%s err=%v\", w.Body.String(), err)\n\t}\n\tif len(response.Channels) != 4 {\n\t\tt.Fatalf(\"响应未返回补齐后的全部渠道: %+v\", response.Channels)\n\t}\n}\n\nfunc TestMissingProviderAccountRoutes(t *testing.T) {\n\ttmpl, ok := config.GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"缺少 deepseek provider 模板\")\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{ServiceType: \"claude\"},\n\t}}\n\tmissing := missingProviderAccountRoutes(tmpl, existing)\n\tif len(missing) != 2 || missing[0].ChannelKind != \"chat\" || missing[1].ChannelKind != \"responses\" {\n\t\tt.Fatalf(\"missing routes = %+v\", missing)\n\t}\n\tconfigs, baseURLs, err := bindProviderRouteKeys(tmpl, missing[0], []string{\"sk-existing\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(configs) != 1 || configs[0].BaseURL != \"https://api.deepseek.com\" || len(baseURLs) != 1 {\n\t\tt.Fatalf(\"route 绑定结果不正确: configs=%+v baseURLs=%v\", configs, baseURLs)\n\t}\n}\n\nfunc TestBindProviderRouteKeysPreservesExistingEndpointAffinity(t *testing.T) {\n\ttests := []struct {\n\t\tproviderID string\n\t\tapiKey string\n\t\texistingBaseURL string\n\t\ttargetKind string\n\t\twantBaseURL string\n\t}{\n\t\t{\n\t\t\tproviderID: \"mimo\", apiKey: \"tp-existing\",\n\t\t\texistingBaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\t\ttargetKind: \"chat\", wantBaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\",\n\t\t},\n\t\t{\n\t\t\tproviderID: \"volcengine\", apiKey: \"ark-existing\",\n\t\t\texistingBaseURL: \"https://ark.cn-beijing.volces.com/api/coding\",\n\t\t\ttargetKind: \"responses\", wantBaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.providerID, func(t *testing.T) {\n\t\t\ttmpl, ok := config.GetProviderTemplate(tt.providerID)\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"缺少 provider 模板: %s\", tt.providerID)\n\t\t\t}\n\t\t\texisting := []config.AccountChannel{{\n\t\t\t\tKind: \"messages\",\n\t\t\t\tUpstream: config.UpstreamConfig{\n\t\t\t\t\tProviderID: tt.providerID, ServiceType: \"claude\", APIKeys: []string{tt.apiKey},\n\t\t\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: tt.apiKey, BaseURL: tt.existingBaseURL}},\n\t\t\t\t},\n\t\t\t}}\n\t\t\tvar target config.ProviderRoute\n\t\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\t\tif route.ChannelKind == tt.targetKind {\n\t\t\t\t\ttarget = route\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tconfigs, _, err := bindProviderRouteKeysWithAffinities(\n\t\t\t\ttmpl, target, []string{tt.apiKey}, providerKeyCandidateAffinities(tmpl, existing),\n\t\t\t)\n\t\t\tif err != nil || len(configs) != 1 || configs[0].BaseURL != tt.wantBaseURL {\n\t\t\t\tt.Fatalf(\"亲和绑定错误: configs=%+v err=%v want=%s\", configs, err, tt.wantBaseURL)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPlanProviderAccountRouteAdditionsRejectsNewKeyBeforeMutation(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t}))\n\tdefer server.Close()\n\n\ttmpl := &config.ProviderTemplate{\n\t\tProviderID: \"test-provider\",\n\t\tRoutes: []config.ProviderRoute{\n\t\t\t{ChannelKind: \"messages\", ServiceType: \"claude\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/anthropic\"}}},\n\t\t\t{ChannelKind: \"chat\", ServiceType: \"openai\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/v1\"}}},\n\t\t},\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct_test\", ChannelUID: \"ch_messages\", Name: \"test-provider\",\n\t\t\tProviderID: \"test-provider\", ServiceType: \"claude\", AutoManaged: true, APIKeys: []string{\"sk-existing\"},\n\t\t},\n\t}}\n\tadditions, status, err := planProviderAccountRouteAdditions(\n\t\tt.Context(), config.Config{}, tmpl, \"acct_test\", []string{\"sk-existing\", \"sk-invalid\"}, existing,\n\t)\n\tif err == nil || status != http.StatusBadRequest || len(additions) != 0 {\n\t\tt.Fatalf(\"无效新 Key 应在生成新增 route 前失败: status=%d additions=%+v err=%v\", status, additions, err)\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}, "af5d87f2974c75050ff24b193802ce7222d59600": {"t": 1785759361.121471, "n": 26866, "h": "bf99534601bfa7a4", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestListAccountsMasksCredentials(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-secret-value\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil)\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"sk-secret-value\") {\n\t\tt.Fatalf(\"账号列表泄露明文 Key: %s\", body)\n\t}\n\tif !strings.Contains(body, \"cred_test\") || !strings.Contains(body, \"keyMask\") {\n\t\tt.Fatalf(\"账号列表缺少凭证掩码信息: %s\", body)\n\t}\n}\n\nfunc TestPatchAccountCredentialsRemovesByUID(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"sk-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"sk-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"baseUrls\":[\"https://api.xiaomimimo.com/anthropic\"],\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPatch, \"/api/accounts/acct_test/credentials\", bytes.NewBufferString(`{\"removeCredentialUids\":[\"cred_b\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PATCH credentials status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 1 || len(channels[0].Upstream.APIKeys) != 1 || channels[0].Upstream.APIKeys[0] != \"sk-a\" {\n\t\tt.Fatalf(\"按 credentialUid 删除失败: %+v\", channels)\n\t}\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCustomAutoAddResponseIncludesActualRoute(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"fastaitoken-com-test\",\"baseUrls\":[\"https://example.com\"],\"apiKeys\":[\"sk-test\"]}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 {\n\t\tt.Fatalf(\"channels=%+v, want one actual route\", response.Channels)\n\t}\n\troute := response.Channels[0]\n\tif route.ChannelKind != \"responses\" || route.ServiceType != \"responses\" || route.ChannelUID != response.ChannelUID {\n\t\tt.Fatalf(\"route=%+v response=%+v\", route, response)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.ResponsesUpstream) != 1 || len(cfg.Upstream) != 0 {\n\t\tt.Fatalf(\"custom route persisted in wrong channel: responses=%d messages=%d\", len(cfg.ResponsesUpstream), len(cfg.Upstream))\n\t}\n}\n\nfunc TestProviderRouteNameAndPrimaryResult(t *testing.T) {\n\tbase := \"mimo-test\"\n\ttests := []struct {\n\t\troute config.ProviderRoute\n\t\twant string\n\t}{\n\t\t{route: config.ProviderRoute{ChannelKind: \"messages\"}, want: \"mimo-test-claude\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"chat\"}, want: \"mimo-test-chat\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"responses\"}, want: \"mimo-test-codex\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"gemini\"}, want: \"mimo-test-gemini\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := providerRouteName(base, tt.route, true); got != tt.want {\n\t\t\tt.Fatalf(\"providerRouteName(%s)=%q, want %q\", tt.route.ChannelKind, got, tt.want)\n\t\t}\n\t}\n\tif got := providerRouteName(base, config.ProviderRoute{ChannelKind: \"messages\"}, false); got != base {\n\t\tt.Fatalf(\"single route name=%q, want %q\", got, base)\n\t}\n\n\tresults := []AutoAddChannelResult{\n\t\t{ChannelKind: \"messages\", ChannelUID: \"ch_messages\", Index: 1},\n\t\t{ChannelKind: \"chat\", ChannelUID: \"ch_chat\", Index: 2},\n\t\t{ChannelKind: \"responses\", ChannelUID: \"ch_responses\", Index: 3},\n\t}\n\tprimary := primaryAutoAddResult(results, \"chat\")\n\tif primary.ChannelUID != \"ch_chat\" || primary.Index != 2 {\n\t\tt.Fatalf(\"primary=%+v, want chat result\", primary)\n\t}\n}\n\nfunc TestManagedAccountUIDForProviderUsesExistingAccount(t *testing.T) {\n\tcfg := config.Config{ManagedAccounts: []config.ManagedAccountConfig{\n\t\t{AccountUID: \"acct-old\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-new\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-deepseek\", ProviderID: \"deepseek\"},\n\t}}\n\tif got := managedAccountUIDForProvider(cfg, \"mimo\"); got != \"acct-new\" {\n\t\tt.Fatalf(\"mimo existing account = %q, want acct-new\", got)\n\t}\n\tif got := managedAccountUIDForProvider(cfg, \"unknown\"); got != \"\" {\n\t\tt.Fatalf(\"unknown provider 不应命中账号: %q\", got)\n\t}\n}\n\nfunc TestListManagedAccountsDoesNotExposeVolcengineSecret(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-secret\",\"volcengineAccessKey\":{\"accessKeyId\":\"AKIDEXAMPLE\",\"secretAccessKey\":\"SECRET_MUST_NOT_LEAK\",\"plan\":\"agent_plan\",\"planTier\":\"Large\",\"planStatus\":\"Running\"}}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"SECRET_MUST_NOT_LEAK\") || strings.Contains(body, \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", body)\n\t}\n\tfor _, expected := range []string{`\"hasVolcengineAccessKey\":true`, `\"volcenginePlan\":\"agent_plan\"`, `\"volcenginePlanTier\":\"Large\"`} {\n\t\tif !strings.Contains(body, expected) {\n\t\t\tt.Fatalf(\"响应缺少 %s: %s\", expected, body)\n\t\t}\n\t}\n}\n\nfunc TestSetVolcengineAccessKeyDetectsPlanBeforePersisting(t *testing.T) {\n\tcontrolPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Query().Get(\"Action\") != \"GetPersonalPlan\" {\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tvar body struct{ Plan string }\n\t\t_ = json.NewDecoder(r.Body).Decode(&body)\n\t\tif body.Plan == \"CodingPlan\" {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t_, _ = w.Write([]byte(`{\"ResponseMetadata\":{\"Error\":{\"Code\":\"ResourceNotFound.Plan\",\"Message\":\"not found\"}}}`))\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\"Result\":{\"PlanType\":\"Large\",\"Status\":\"Running\"}}`))\n\t}))\n\tdefer controlPlane.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-inference\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\trunner := NewAutoDiscoveryRunner(nil, nil)\n\trunner.client = controlPlane.Client()\n\trunner.volcengineControlPlaneEndpoint = controlPlane.URL\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, Runner: runner})\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_volc/credentials/cred_volc/volcengine-access-key\", bytes.NewBufferString(`{\"accessKeyId\":\"AKID\",\"secretAccessKey\":\"SECRET\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"plan\":\"agent_plan\"`) {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_volc\", \"cred_volc\")\n\tif !ok || credential.VolcengineAccessKey == nil || credential.VolcengineAccessKey.SecretAccessKey != \"SECRET\" || credential.VolcengineAccessKey.Plan != \"agent_plan\" {\n\t\tt.Fatalf(\"凭证未正确持久化: %+v\", credential)\n\t}\n}\n\nfunc TestSetMiMoConsoleCookieRequiresConfirmationBeforeAdoptingKey(t *testing.T) {\n\tconst oldKey = \"tp-old-1234567890123456789012345678901234567890\"\n\tconst cookieKey = \"tp-new-1234567890123456789012345678901234567890\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != \"api-platform_serviceToken=session; userId=42\" {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase \"/api/v1/userProfile\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"userId\":\"42\"}}`))\n\t\tcase \"/api/v1/tokenPlan/detail\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"planCode\":\"max\",\"planName\":\"Max\",\"currentPeriodEnd\":\"2026-07-29 23:59:59\"}}`))\n\t\tcase \"/api/v1/tokenPlan/usage\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"monthUsage\":{\"percent\":0.25,\"items\":[{\"used\":25,\"limit\":100}]},\"usage\":{\"percent\":0.5,\"items\":[{\"used\":50,\"limit\":100}]}}}`))\n\t\tcase \"/api/v1/tokenPlan/apiKey/raw\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":\"` + cookieKey + `\"}`))\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}\n\t}))\n\tdefer console.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"credentials\":[{\"credentialUid\":\"cred_mimo\",\"apiKey\":\"` + oldKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_mimo\",\"channelUid\":\"ch_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_mimo\",\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\tdeps := &AutoManagedDeps{CfgManager: manager, MiMoConsoleClient: &MiMoConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL}}\n\trouter := setupAutoManagedRouter(deps)\n\tbody := `{\"cookie\":\"api-platform_serviceToken=session; userId=42\"}`\n\trequest := func(payload string) *httptest.ResponseRecorder {\n\t\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_mimo/credentials/cred_mimo/mimo-console-cookie\", bytes.NewBufferString(payload))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tw := httptest.NewRecorder()\n\t\trouter.ServeHTTP(w, req)\n\t\treturn w\n\t}\n\tw := request(body)\n\tif w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), `\"code\":\"mimo_cookie_key_mismatch\"`) {\n\t\tt.Fatalf(\"未要求确认: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ := manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != oldKey || credential.MiMoConsole != nil {\n\t\tt.Fatalf(\"确认前不应修改配置: %+v\", credential)\n\t}\n\tw = request(`{\"cookie\":\"api-platform_serviceToken=session; userId=42\",\"adoptCookieKey\":true}`)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"keyAdopted\":true`) {\n\t\tt.Fatalf(\"确认采用失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ = manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != cookieKey || credential.MiMoConsole == nil {\n\t\tt.Fatalf(\"确认后未采用 Cookie Key: %+v\", credential)\n\t}\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif strings.Contains(list.Body.String(), \"api-platform_serviceToken\") || strings.Contains(list.Body.String(), cookieKey) {\n\t\tt.Fatalf(\"账号列表泄漏 Cookie 或原始 Key: %s\", list.Body.String())\n\t}\n}\n\nfunc TestProviderAutoAddReusesExistingAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(`{\"providerId\":\"mimo\",\"apiKeys\":[\"sk-existing\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"追加已有 provider key status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 4 {\n\t\tt.Fatalf(\"旧账号应自动补齐模板中的协议渠道: %+v\", channels)\n\t}\n\tkinds := make(map[string]bool, len(channels))\n\tfor _, channel := range channels {\n\t\tkinds[channel.Kind] = true\n\t}\n\tfor _, kind := range []string{\"messages\", \"chat\", \"responses\", \"gemini\"} {\n\t\tif !kinds[kind] {\n\t\t\tt.Fatalf(\"旧账号缺少 %s route: %+v\", kind, channels)\n\t\t}\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.AccountUID != \"acct_test\" {\n\t\tt.Fatalf(\"响应未返回已有账号: body=%s err=%v\", w.Body.String(), err)\n\t}\n\tif len(response.Channels) != 4 {\n\t\tt.Fatalf(\"响应未返回补齐后的全部渠道: %+v\", response.Channels)\n\t}\n}\n\nfunc TestMissingProviderAccountRoutes(t *testing.T) {\n\ttmpl, ok := config.GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"缺少 deepseek provider 模板\")\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{ServiceType: \"claude\"},\n\t}}\n\tmissing := missingProviderAccountRoutes(tmpl, existing)\n\tif len(missing) != 2 || missing[0].ChannelKind != \"chat\" || missing[1].ChannelKind != \"responses\" {\n\t\tt.Fatalf(\"missing routes = %+v\", missing)\n\t}\n\tconfigs, baseURLs, err := bindProviderRouteKeys(tmpl, missing[0], []string{\"sk-existing\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(configs) != 1 || configs[0].BaseURL != \"https://api.deepseek.com\" || len(baseURLs) != 1 {\n\t\tt.Fatalf(\"route 绑定结果不正确: configs=%+v baseURLs=%v\", configs, baseURLs)\n\t}\n}\n\nfunc TestBindProviderRouteKeysPreservesExistingEndpointAffinity(t *testing.T) {\n\ttests := []struct {\n\t\tproviderID string\n\t\tapiKey string\n\t\texistingBaseURL string\n\t\ttargetKind string\n\t\twantBaseURL string\n\t}{\n\t\t{\n\t\t\tproviderID: \"mimo\", apiKey: \"tp-existing\",\n\t\t\texistingBaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\t\ttargetKind: \"chat\", wantBaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\",\n\t\t},\n\t\t{\n\t\t\tproviderID: \"volcengine\", apiKey: \"ark-existing\",\n\t\t\texistingBaseURL: \"https://ark.cn-beijing.volces.com/api/coding\",\n\t\t\ttargetKind: \"responses\", wantBaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.providerID, func(t *testing.T) {\n\t\t\ttmpl, ok := config.GetProviderTemplate(tt.providerID)\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"缺少 provider 模板: %s\", tt.providerID)\n\t\t\t}\n\t\t\texisting := []config.AccountChannel{{\n\t\t\t\tKind: \"messages\",\n\t\t\t\tUpstream: config.UpstreamConfig{\n\t\t\t\t\tProviderID: tt.providerID, ServiceType: \"claude\", APIKeys: []string{tt.apiKey},\n\t\t\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: tt.apiKey, BaseURL: tt.existingBaseURL}},\n\t\t\t\t},\n\t\t\t}}\n\t\t\tvar target config.ProviderRoute\n\t\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\t\tif route.ChannelKind == tt.targetKind {\n\t\t\t\t\ttarget = route\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tconfigs, _, err := bindProviderRouteKeysWithAffinities(\n\t\t\t\ttmpl, target, []string{tt.apiKey}, providerKeyCandidateAffinities(tmpl, existing),\n\t\t\t)\n\t\t\tif err != nil || len(configs) != 1 || configs[0].BaseURL != tt.wantBaseURL {\n\t\t\t\tt.Fatalf(\"亲和绑定错误: configs=%+v err=%v want=%s\", configs, err, tt.wantBaseURL)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPlanProviderAccountRouteAdditionsRejectsNewKeyBeforeMutation(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t}))\n\tdefer server.Close()\n\n\ttmpl := &config.ProviderTemplate{\n\t\tProviderID: \"test-provider\",\n\t\tRoutes: []config.ProviderRoute{\n\t\t\t{ChannelKind: \"messages\", ServiceType: \"claude\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/anthropic\"}}},\n\t\t\t{ChannelKind: \"chat\", ServiceType: \"openai\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/v1\"}}},\n\t\t},\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct_test\", ChannelUID: \"ch_messages\", Name: \"test-provider\",\n\t\t\tProviderID: \"test-provider\", ServiceType: \"claude\", AutoManaged: true, APIKeys: []string{\"sk-existing\"},\n\t\t},\n\t}}\n\tadditions, status, err := planProviderAccountRouteAdditions(\n\t\tt.Context(), config.Config{}, tmpl, \"acct_test\", []string{\"sk-existing\", \"sk-invalid\"}, existing,\n\t)\n\tif err == nil || status != http.StatusBadRequest || len(additions) != 0 {\n\t\tt.Fatalf(\"无效新 Key 应在生成新增 route 前失败: status=%d additions=%+v err=%v\", status, additions, err)\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}, "5d2ea67008b8062dd3b4f7104a16827e50c393b3": {"t": 1785759363.2441647, "n": 25204, "h": "4c3e54dc84c3bec9", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestListAccountsMasksCredentials(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-secret-value\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil)\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"sk-secret-value\") {\n\t\tt.Fatalf(\"账号列表泄露明文 Key: %s\", body)\n\t}\n\tif !strings.Contains(body, \"cred_test\") || !strings.Contains(body, \"keyMask\") {\n\t\tt.Fatalf(\"账号列表缺少凭证掩码信息: %s\", body)\n\t}\n}\n\nfunc TestPatchAccountCredentialsRemovesByUID(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"sk-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"sk-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"baseUrls\":[\"https://api.xiaomimimo.com/anthropic\"],\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPatch, \"/api/accounts/acct_test/credentials\", bytes.NewBufferString(`{\"removeCredentialUids\":[\"cred_b\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PATCH credentials status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 1 || len(channels[0].Upstream.APIKeys) != 1 || channels[0].Upstream.APIKeys[0] != \"sk-a\" {\n\t\tt.Fatalf(\"按 credentialUid 删除失败: %+v\", channels)\n\t}\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestProviderRouteNameAndPrimaryResult(t *testing.T) {\n\tbase := \"mimo-test\"\n\ttests := []struct {\n\t\troute config.ProviderRoute\n\t\twant string\n\t}{\n\t\t{route: config.ProviderRoute{ChannelKind: \"messages\"}, want: \"mimo-test-claude\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"chat\"}, want: \"mimo-test-chat\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"responses\"}, want: \"mimo-test-codex\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"gemini\"}, want: \"mimo-test-gemini\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := providerRouteName(base, tt.route, true); got != tt.want {\n\t\t\tt.Fatalf(\"providerRouteName(%s)=%q, want %q\", tt.route.ChannelKind, got, tt.want)\n\t\t}\n\t}\n\tif got := providerRouteName(base, config.ProviderRoute{ChannelKind: \"messages\"}, false); got != base {\n\t\tt.Fatalf(\"single route name=%q, want %q\", got, base)\n\t}\n\n\tresults := []AutoAddChannelResult{\n\t\t{ChannelKind: \"messages\", ChannelUID: \"ch_messages\", Index: 1},\n\t\t{ChannelKind: \"chat\", ChannelUID: \"ch_chat\", Index: 2},\n\t\t{ChannelKind: \"responses\", ChannelUID: \"ch_responses\", Index: 3},\n\t}\n\tprimary := primaryAutoAddResult(results, \"chat\")\n\tif primary.ChannelUID != \"ch_chat\" || primary.Index != 2 {\n\t\tt.Fatalf(\"primary=%+v, want chat result\", primary)\n\t}\n}\n\nfunc TestManagedAccountUIDForProviderUsesExistingAccount(t *testing.T) {\n\tcfg := config.Config{ManagedAccounts: []config.ManagedAccountConfig{\n\t\t{AccountUID: \"acct-old\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-new\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-deepseek\", ProviderID: \"deepseek\"},\n\t}}\n\tif got := managedAccountUIDForProvider(cfg, \"mimo\"); got != \"acct-new\" {\n\t\tt.Fatalf(\"mimo existing account = %q, want acct-new\", got)\n\t}\n\tif got := managedAccountUIDForProvider(cfg, \"unknown\"); got != \"\" {\n\t\tt.Fatalf(\"unknown provider 不应命中账号: %q\", got)\n\t}\n}\n\nfunc TestListManagedAccountsDoesNotExposeVolcengineSecret(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-secret\",\"volcengineAccessKey\":{\"accessKeyId\":\"AKIDEXAMPLE\",\"secretAccessKey\":\"SECRET_MUST_NOT_LEAK\",\"plan\":\"agent_plan\",\"planTier\":\"Large\",\"planStatus\":\"Running\"}}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"SECRET_MUST_NOT_LEAK\") || strings.Contains(body, \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", body)\n\t}\n\tfor _, expected := range []string{`\"hasVolcengineAccessKey\":true`, `\"volcenginePlan\":\"agent_plan\"`, `\"volcenginePlanTier\":\"Large\"`} {\n\t\tif !strings.Contains(body, expected) {\n\t\t\tt.Fatalf(\"响应缺少 %s: %s\", expected, body)\n\t\t}\n\t}\n}\n\nfunc TestSetVolcengineAccessKeyDetectsPlanBeforePersisting(t *testing.T) {\n\tcontrolPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Query().Get(\"Action\") != \"GetPersonalPlan\" {\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tvar body struct{ Plan string }\n\t\t_ = json.NewDecoder(r.Body).Decode(&body)\n\t\tif body.Plan == \"CodingPlan\" {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t_, _ = w.Write([]byte(`{\"ResponseMetadata\":{\"Error\":{\"Code\":\"ResourceNotFound.Plan\",\"Message\":\"not found\"}}}`))\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\"Result\":{\"PlanType\":\"Large\",\"Status\":\"Running\"}}`))\n\t}))\n\tdefer controlPlane.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-inference\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\trunner := NewAutoDiscoveryRunner(nil, nil)\n\trunner.client = controlPlane.Client()\n\trunner.volcengineControlPlaneEndpoint = controlPlane.URL\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, Runner: runner})\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_volc/credentials/cred_volc/volcengine-access-key\", bytes.NewBufferString(`{\"accessKeyId\":\"AKID\",\"secretAccessKey\":\"SECRET\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"plan\":\"agent_plan\"`) {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_volc\", \"cred_volc\")\n\tif !ok || credential.VolcengineAccessKey == nil || credential.VolcengineAccessKey.SecretAccessKey != \"SECRET\" || credential.VolcengineAccessKey.Plan != \"agent_plan\" {\n\t\tt.Fatalf(\"凭证未正确持久化: %+v\", credential)\n\t}\n}\n\nfunc TestSetMiMoConsoleCookieRequiresConfirmationBeforeAdoptingKey(t *testing.T) {\n\tconst oldKey = \"tp-old-1234567890123456789012345678901234567890\"\n\tconst cookieKey = \"tp-new-1234567890123456789012345678901234567890\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != \"api-platform_serviceToken=session; userId=42\" {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase \"/api/v1/userProfile\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"userId\":\"42\"}}`))\n\t\tcase \"/api/v1/tokenPlan/detail\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"planCode\":\"max\",\"planName\":\"Max\",\"currentPeriodEnd\":\"2026-07-29 23:59:59\"}}`))\n\t\tcase \"/api/v1/tokenPlan/usage\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"monthUsage\":{\"percent\":0.25,\"items\":[{\"used\":25,\"limit\":100}]},\"usage\":{\"percent\":0.5,\"items\":[{\"used\":50,\"limit\":100}]}}}`))\n\t\tcase \"/api/v1/tokenPlan/apiKey/raw\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":\"` + cookieKey + `\"}`))\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}\n\t}))\n\tdefer console.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"credentials\":[{\"credentialUid\":\"cred_mimo\",\"apiKey\":\"` + oldKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_mimo\",\"channelUid\":\"ch_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_mimo\",\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\tdeps := &AutoManagedDeps{CfgManager: manager, MiMoConsoleClient: &MiMoConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL}}\n\trouter := setupAutoManagedRouter(deps)\n\tbody := `{\"cookie\":\"api-platform_serviceToken=session; userId=42\"}`\n\trequest := func(payload string) *httptest.ResponseRecorder {\n\t\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_mimo/credentials/cred_mimo/mimo-console-cookie\", bytes.NewBufferString(payload))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tw := httptest.NewRecorder()\n\t\trouter.ServeHTTP(w, req)\n\t\treturn w\n\t}\n\tw := request(body)\n\tif w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), `\"code\":\"mimo_cookie_key_mismatch\"`) {\n\t\tt.Fatalf(\"未要求确认: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ := manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != oldKey || credential.MiMoConsole != nil {\n\t\tt.Fatalf(\"确认前不应修改配置: %+v\", credential)\n\t}\n\tw = request(`{\"cookie\":\"api-platform_serviceToken=session; userId=42\",\"adoptCookieKey\":true}`)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"keyAdopted\":true`) {\n\t\tt.Fatalf(\"确认采用失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ = manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != cookieKey || credential.MiMoConsole == nil {\n\t\tt.Fatalf(\"确认后未采用 Cookie Key: %+v\", credential)\n\t}\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif strings.Contains(list.Body.String(), \"api-platform_serviceToken\") || strings.Contains(list.Body.String(), cookieKey) {\n\t\tt.Fatalf(\"账号列表泄漏 Cookie 或原始 Key: %s\", list.Body.String())\n\t}\n}\n\nfunc TestProviderAutoAddReusesExistingAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(`{\"providerId\":\"mimo\",\"apiKeys\":[\"sk-existing\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"追加已有 provider key status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 4 {\n\t\tt.Fatalf(\"旧账号应自动补齐模板中的协议渠道: %+v\", channels)\n\t}\n\tkinds := make(map[string]bool, len(channels))\n\tfor _, channel := range channels {\n\t\tkinds[channel.Kind] = true\n\t}\n\tfor _, kind := range []string{\"messages\", \"chat\", \"responses\", \"gemini\"} {\n\t\tif !kinds[kind] {\n\t\t\tt.Fatalf(\"旧账号缺少 %s route: %+v\", kind, channels)\n\t\t}\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.AccountUID != \"acct_test\" {\n\t\tt.Fatalf(\"响应未返回已有账号: body=%s err=%v\", w.Body.String(), err)\n\t}\n\tif len(response.Channels) != 4 {\n\t\tt.Fatalf(\"响应未返回补齐后的全部渠道: %+v\", response.Channels)\n\t}\n}\n\nfunc TestMissingProviderAccountRoutes(t *testing.T) {\n\ttmpl, ok := config.GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"缺少 deepseek provider 模板\")\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{ServiceType: \"claude\"},\n\t}}\n\tmissing := missingProviderAccountRoutes(tmpl, existing)\n\tif len(missing) != 2 || missing[0].ChannelKind != \"chat\" || missing[1].ChannelKind != \"responses\" {\n\t\tt.Fatalf(\"missing routes = %+v\", missing)\n\t}\n\tconfigs, baseURLs, err := bindProviderRouteKeys(tmpl, missing[0], []string{\"sk-existing\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(configs) != 1 || configs[0].BaseURL != \"https://api.deepseek.com\" || len(baseURLs) != 1 {\n\t\tt.Fatalf(\"route 绑定结果不正确: configs=%+v baseURLs=%v\", configs, baseURLs)\n\t}\n}\n\nfunc TestBindProviderRouteKeysPreservesExistingEndpointAffinity(t *testing.T) {\n\ttests := []struct {\n\t\tproviderID string\n\t\tapiKey string\n\t\texistingBaseURL string\n\t\ttargetKind string\n\t\twantBaseURL string\n\t}{\n\t\t{\n\t\t\tproviderID: \"mimo\", apiKey: \"tp-existing\",\n\t\t\texistingBaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\t\ttargetKind: \"chat\", wantBaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\",\n\t\t},\n\t\t{\n\t\t\tproviderID: \"volcengine\", apiKey: \"ark-existing\",\n\t\t\texistingBaseURL: \"https://ark.cn-beijing.volces.com/api/coding\",\n\t\t\ttargetKind: \"responses\", wantBaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.providerID, func(t *testing.T) {\n\t\t\ttmpl, ok := config.GetProviderTemplate(tt.providerID)\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"缺少 provider 模板: %s\", tt.providerID)\n\t\t\t}\n\t\t\texisting := []config.AccountChannel{{\n\t\t\t\tKind: \"messages\",\n\t\t\t\tUpstream: config.UpstreamConfig{\n\t\t\t\t\tProviderID: tt.providerID, ServiceType: \"claude\", APIKeys: []string{tt.apiKey},\n\t\t\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: tt.apiKey, BaseURL: tt.existingBaseURL}},\n\t\t\t\t},\n\t\t\t}}\n\t\t\tvar target config.ProviderRoute\n\t\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\t\tif route.ChannelKind == tt.targetKind {\n\t\t\t\t\ttarget = route\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tconfigs, _, err := bindProviderRouteKeysWithAffinities(\n\t\t\t\ttmpl, target, []string{tt.apiKey}, providerKeyCandidateAffinities(tmpl, existing),\n\t\t\t)\n\t\t\tif err != nil || len(configs) != 1 || configs[0].BaseURL != tt.wantBaseURL {\n\t\t\t\tt.Fatalf(\"亲和绑定错误: configs=%+v err=%v want=%s\", configs, err, tt.wantBaseURL)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPlanProviderAccountRouteAdditionsRejectsNewKeyBeforeMutation(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t}))\n\tdefer server.Close()\n\n\ttmpl := &config.ProviderTemplate{\n\t\tProviderID: \"test-provider\",\n\t\tRoutes: []config.ProviderRoute{\n\t\t\t{ChannelKind: \"messages\", ServiceType: \"claude\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/anthropic\"}}},\n\t\t\t{ChannelKind: \"chat\", ServiceType: \"openai\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/v1\"}}},\n\t\t},\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct_test\", ChannelUID: \"ch_messages\", Name: \"test-provider\",\n\t\t\tProviderID: \"test-provider\", ServiceType: \"claude\", AutoManaged: true, APIKeys: []string{\"sk-existing\"},\n\t\t},\n\t}}\n\tadditions, status, err := planProviderAccountRouteAdditions(\n\t\tt.Context(), config.Config{}, tmpl, \"acct_test\", []string{\"sk-existing\", \"sk-invalid\"}, existing,\n\t)\n\tif err == nil || status != http.StatusBadRequest || len(additions) != 0 {\n\t\tt.Fatalf(\"无效新 Key 应在生成新增 route 前失败: status=%d additions=%+v err=%v\", status, additions, err)\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}, "d1b25677bfffa5238b10a563032d61104ddc6545": {"t": 1785759367.4275293, "n": 21210, "h": "efaa2704a12558e4", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestListAccountsMasksCredentials(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-secret-value\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil)\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"sk-secret-value\") {\n\t\tt.Fatalf(\"账号列表泄露明文 Key: %s\", body)\n\t}\n\tif !strings.Contains(body, \"cred_test\") || !strings.Contains(body, \"keyMask\") {\n\t\tt.Fatalf(\"账号列表缺少凭证掩码信息: %s\", body)\n\t}\n}\n\nfunc TestPatchAccountCredentialsRemovesByUID(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"sk-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"sk-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"baseUrls\":[\"https://api.xiaomimimo.com/anthropic\"],\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPatch, \"/api/accounts/acct_test/credentials\", bytes.NewBufferString(`{\"removeCredentialUids\":[\"cred_b\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PATCH credentials status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 1 || len(channels[0].Upstream.APIKeys) != 1 || channels[0].Upstream.APIKeys[0] != \"sk-a\" {\n\t\tt.Fatalf(\"按 credentialUid 删除失败: %+v\", channels)\n\t}\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestProviderRouteNameAndPrimaryResult(t *testing.T) {\n\tbase := \"mimo-test\"\n\ttests := []struct {\n\t\troute config.ProviderRoute\n\t\twant string\n\t}{\n\t\t{route: config.ProviderRoute{ChannelKind: \"messages\"}, want: \"mimo-test-claude\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"chat\"}, want: \"mimo-test-chat\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"responses\"}, want: \"mimo-test-codex\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"gemini\"}, want: \"mimo-test-gemini\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := providerRouteName(base, tt.route, true); got != tt.want {\n\t\t\tt.Fatalf(\"providerRouteName(%s)=%q, want %q\", tt.route.ChannelKind, got, tt.want)\n\t\t}\n\t}\n\tif got := providerRouteName(base, config.ProviderRoute{ChannelKind: \"messages\"}, false); got != base {\n\t\tt.Fatalf(\"single route name=%q, want %q\", got, base)\n\t}\n\n\tresults := []AutoAddChannelResult{\n\t\t{ChannelKind: \"messages\", ChannelUID: \"ch_messages\", Index: 1},\n\t\t{ChannelKind: \"chat\", ChannelUID: \"ch_chat\", Index: 2},\n\t\t{ChannelKind: \"responses\", ChannelUID: \"ch_responses\", Index: 3},\n\t}\n\tprimary := primaryAutoAddResult(results, \"chat\")\n\tif primary.ChannelUID != \"ch_chat\" || primary.Index != 2 {\n\t\tt.Fatalf(\"primary=%+v, want chat result\", primary)\n\t}\n}\n\nfunc TestManagedAccountUIDForProviderUsesExistingAccount(t *testing.T) {\n\tcfg := config.Config{ManagedAccounts: []config.ManagedAccountConfig{\n\t\t{AccountUID: \"acct-old\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-new\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-deepseek\", ProviderID: \"deepseek\"},\n\t}}\n\tif got := managedAccountUIDForProvider(cfg, \"mimo\"); got != \"acct-new\" {\n\t\tt.Fatalf(\"mimo existing account = %q, want acct-new\", got)\n\t}\n\tif got := managedAccountUIDForProvider(cfg, \"unknown\"); got != \"\" {\n\t\tt.Fatalf(\"unknown provider 不应命中账号: %q\", got)\n\t}\n}\n\nfunc TestListManagedAccountsDoesNotExposeVolcengineSecret(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-secret\",\"volcengineAccessKey\":{\"accessKeyId\":\"AKIDEXAMPLE\",\"secretAccessKey\":\"SECRET_MUST_NOT_LEAK\",\"plan\":\"agent_plan\",\"planTier\":\"Large\",\"planStatus\":\"Running\"}}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"SECRET_MUST_NOT_LEAK\") || strings.Contains(body, \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", body)\n\t}\n\tfor _, expected := range []string{`\"hasVolcengineAccessKey\":true`, `\"volcenginePlan\":\"agent_plan\"`, `\"volcenginePlanTier\":\"Large\"`} {\n\t\tif !strings.Contains(body, expected) {\n\t\t\tt.Fatalf(\"响应缺少 %s: %s\", expected, body)\n\t\t}\n\t}\n}\n\nfunc TestSetVolcengineAccessKeyDetectsPlanBeforePersisting(t *testing.T) {\n\tcontrolPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Query().Get(\"Action\") != \"GetPersonalPlan\" {\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tvar body struct{ Plan string }\n\t\t_ = json.NewDecoder(r.Body).Decode(&body)\n\t\tif body.Plan == \"CodingPlan\" {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t_, _ = w.Write([]byte(`{\"ResponseMetadata\":{\"Error\":{\"Code\":\"ResourceNotFound.Plan\",\"Message\":\"not found\"}}}`))\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\"Result\":{\"PlanType\":\"Large\",\"Status\":\"Running\"}}`))\n\t}))\n\tdefer controlPlane.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-inference\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\trunner := NewAutoDiscoveryRunner(nil, nil)\n\trunner.client = controlPlane.Client()\n\trunner.volcengineControlPlaneEndpoint = controlPlane.URL\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, Runner: runner})\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_volc/credentials/cred_volc/volcengine-access-key\", bytes.NewBufferString(`{\"accessKeyId\":\"AKID\",\"secretAccessKey\":\"SECRET\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"plan\":\"agent_plan\"`) {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_volc\", \"cred_volc\")\n\tif !ok || credential.VolcengineAccessKey == nil || credential.VolcengineAccessKey.SecretAccessKey != \"SECRET\" || credential.VolcengineAccessKey.Plan != \"agent_plan\" {\n\t\tt.Fatalf(\"凭证未正确持久化: %+v\", credential)\n\t}\n}\n\nfunc TestSetMiMoConsoleCookieRequiresConfirmationBeforeAdoptingKey(t *testing.T) {\n\tconst oldKey = \"tp-old-1234567890123456789012345678901234567890\"\n\tconst cookieKey = \"tp-new-1234567890123456789012345678901234567890\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != \"api-platform_serviceToken=session; userId=42\" {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase \"/api/v1/userProfile\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"userId\":\"42\"}}`))\n\t\tcase \"/api/v1/tokenPlan/detail\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"planCode\":\"max\",\"planName\":\"Max\",\"currentPeriodEnd\":\"2026-07-29 23:59:59\"}}`))\n\t\tcase \"/api/v1/tokenPlan/usage\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"monthUsage\":{\"percent\":0.25,\"items\":[{\"used\":25,\"limit\":100}]},\"usage\":{\"percent\":0.5,\"items\":[{\"used\":50,\"limit\":100}]}}}`))\n\t\tcase \"/api/v1/tokenPlan/apiKey/raw\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":\"` + cookieKey + `\"}`))\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}\n\t}))\n\tdefer console.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"credentials\":[{\"credentialUid\":\"cred_mimo\",\"apiKey\":\"` + oldKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_mimo\",\"channelUid\":\"ch_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_mimo\",\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\tdeps := &AutoManagedDeps{CfgManager: manager, MiMoConsoleClient: &MiMoConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL}}\n\trouter := setupAutoManagedRouter(deps)\n\tbody := `{\"cookie\":\"api-platform_serviceToken=session; userId=42\"}`\n\trequest := func(payload string) *httptest.ResponseRecorder {\n\t\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_mimo/credentials/cred_mimo/mimo-console-cookie\", bytes.NewBufferString(payload))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tw := httptest.NewRecorder()\n\t\trouter.ServeHTTP(w, req)\n\t\treturn w\n\t}\n\tw := request(body)\n\tif w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), `\"code\":\"mimo_cookie_key_mismatch\"`) {\n\t\tt.Fatalf(\"未要求确认: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ := manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != oldKey || credential.MiMoConsole != nil {\n\t\tt.Fatalf(\"确认前不应修改配置: %+v\", credential)\n\t}\n\tw = request(`{\"cookie\":\"api-platform_serviceToken=session; userId=42\",\"adoptCookieKey\":true}`)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"keyAdopted\":true`) {\n\t\tt.Fatalf(\"确认采用失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ = manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != cookieKey || credential.MiMoConsole == nil {\n\t\tt.Fatalf(\"确认后未采用 Cookie Key: %+v\", credential)\n\t}\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif strings.Contains(list.Body.String(), \"api-platform_serviceToken\") || strings.Contains(list.Body.String(), cookieKey) {\n\t\tt.Fatalf(\"账号列表泄漏 Cookie 或原始 Key: %s\", list.Body.String())\n\t}\n}\n\nfunc TestProviderAutoAddReusesExistingAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(`{\"providerId\":\"mimo\",\"apiKeys\":[\"sk-existing\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"追加已有 provider key status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 1 || channels[0].Upstream.ChannelUID != \"ch_messages\" {\n\t\tt.Fatalf(\"不应创建新渠道: %+v\", channels)\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.AccountUID != \"acct_test\" {\n\t\tt.Fatalf(\"响应未返回已有账号: body=%s err=%v\", w.Body.String(), err)\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}, "6dae122a2a3fa3a18aaf07992905ade59b215860": {"t": 1785759368.953962, "n": 17487, "h": "ea8c10588ced162e", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestListAccountsMasksCredentials(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-secret-value\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil)\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"sk-secret-value\") {\n\t\tt.Fatalf(\"账号列表泄露明文 Key: %s\", body)\n\t}\n\tif !strings.Contains(body, \"cred_test\") || !strings.Contains(body, \"keyMask\") {\n\t\tt.Fatalf(\"账号列表缺少凭证掩码信息: %s\", body)\n\t}\n}\n\nfunc TestPatchAccountCredentialsRemovesByUID(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"sk-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"sk-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"baseUrls\":[\"https://api.xiaomimimo.com/anthropic\"],\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPatch, \"/api/accounts/acct_test/credentials\", bytes.NewBufferString(`{\"removeCredentialUids\":[\"cred_b\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PATCH credentials status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 1 || len(channels[0].Upstream.APIKeys) != 1 || channels[0].Upstream.APIKeys[0] != \"sk-a\" {\n\t\tt.Fatalf(\"按 credentialUid 删除失败: %+v\", channels)\n\t}\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestProviderRouteNameAndPrimaryResult(t *testing.T) {\n\tbase := \"mimo-test\"\n\ttests := []struct {\n\t\troute config.ProviderRoute\n\t\twant string\n\t}{\n\t\t{route: config.ProviderRoute{ChannelKind: \"messages\"}, want: \"mimo-test-claude\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"chat\"}, want: \"mimo-test-chat\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"responses\"}, want: \"mimo-test-codex\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"gemini\"}, want: \"mimo-test-gemini\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := providerRouteName(base, tt.route, true); got != tt.want {\n\t\t\tt.Fatalf(\"providerRouteName(%s)=%q, want %q\", tt.route.ChannelKind, got, tt.want)\n\t\t}\n\t}\n\tif got := providerRouteName(base, config.ProviderRoute{ChannelKind: \"messages\"}, false); got != base {\n\t\tt.Fatalf(\"single route name=%q, want %q\", got, base)\n\t}\n\n\tresults := []AutoAddChannelResult{\n\t\t{ChannelKind: \"messages\", ChannelUID: \"ch_messages\", Index: 1},\n\t\t{ChannelKind: \"chat\", ChannelUID: \"ch_chat\", Index: 2},\n\t\t{ChannelKind: \"responses\", ChannelUID: \"ch_responses\", Index: 3},\n\t}\n\tprimary := primaryAutoAddResult(results, \"chat\")\n\tif primary.ChannelUID != \"ch_chat\" || primary.Index != 2 {\n\t\tt.Fatalf(\"primary=%+v, want chat result\", primary)\n\t}\n}\n\nfunc TestManagedAccountUIDForProviderUsesExistingAccount(t *testing.T) {\n\tcfg := config.Config{ManagedAccounts: []config.ManagedAccountConfig{\n\t\t{AccountUID: \"acct-old\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-new\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-deepseek\", ProviderID: \"deepseek\"},\n\t}}\n\tif got := managedAccountUIDForProvider(cfg, \"mimo\"); got != \"acct-new\" {\n\t\tt.Fatalf(\"mimo existing account = %q, want acct-new\", got)\n\t}\n\tif got := managedAccountUIDForProvider(cfg, \"unknown\"); got != \"\" {\n\t\tt.Fatalf(\"unknown provider 不应命中账号: %q\", got)\n\t}\n}\n\nfunc TestListManagedAccountsDoesNotExposeVolcengineSecret(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-secret\",\"volcengineAccessKey\":{\"accessKeyId\":\"AKIDEXAMPLE\",\"secretAccessKey\":\"SECRET_MUST_NOT_LEAK\",\"plan\":\"agent_plan\",\"planTier\":\"Large\",\"planStatus\":\"Running\"}}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"SECRET_MUST_NOT_LEAK\") || strings.Contains(body, \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", body)\n\t}\n\tfor _, expected := range []string{`\"hasVolcengineAccessKey\":true`, `\"volcenginePlan\":\"agent_plan\"`, `\"volcenginePlanTier\":\"Large\"`} {\n\t\tif !strings.Contains(body, expected) {\n\t\t\tt.Fatalf(\"响应缺少 %s: %s\", expected, body)\n\t\t}\n\t}\n}\n\nfunc TestSetVolcengineAccessKeyDetectsPlanBeforePersisting(t *testing.T) {\n\tcontrolPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Query().Get(\"Action\") != \"GetPersonalPlan\" {\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tvar body struct{ Plan string }\n\t\t_ = json.NewDecoder(r.Body).Decode(&body)\n\t\tif body.Plan == \"CodingPlan\" {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t_, _ = w.Write([]byte(`{\"ResponseMetadata\":{\"Error\":{\"Code\":\"ResourceNotFound.Plan\",\"Message\":\"not found\"}}}`))\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\"Result\":{\"PlanType\":\"Large\",\"Status\":\"Running\"}}`))\n\t}))\n\tdefer controlPlane.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-inference\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer manager.Close()\n\trunner := NewAutoDiscoveryRunner(nil, nil)\n\trunner.client = controlPlane.Client()\n\trunner.volcengineControlPlaneEndpoint = controlPlane.URL\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, Runner: runner})\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_volc/credentials/cred_volc/volcengine-access-key\", bytes.NewBufferString(`{\"accessKeyId\":\"AKID\",\"secretAccessKey\":\"SECRET\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"plan\":\"agent_plan\"`) {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_volc\", \"cred_volc\")\n\tif !ok || credential.VolcengineAccessKey == nil || credential.VolcengineAccessKey.SecretAccessKey != \"SECRET\" || credential.VolcengineAccessKey.Plan != \"agent_plan\" {\n\t\tt.Fatalf(\"凭证未正确持久化: %+v\", credential)\n\t}\n}\n\nfunc TestProviderAutoAddReusesExistingAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(`{\"providerId\":\"mimo\",\"apiKeys\":[\"sk-existing\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"追加已有 provider key status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 1 || channels[0].Upstream.ChannelUID != \"ch_messages\" {\n\t\tt.Fatalf(\"不应创建新渠道: %+v\", channels)\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.AccountUID != \"acct_test\" {\n\t\tt.Fatalf(\"响应未返回已有账号: body=%s err=%v\", w.Body.String(), err)\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}, "cd75f8848172aa79f83a5e253fa8d0dd086ad06d": {"t": 1785759370.7182171, "n": 13185, "h": "64ff94e7274494f1", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestListAccountsMasksCredentials(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-secret-value\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil)\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"sk-secret-value\") {\n\t\tt.Fatalf(\"账号列表泄露明文 Key: %s\", body)\n\t}\n\tif !strings.Contains(body, \"cred_test\") || !strings.Contains(body, \"keyMask\") {\n\t\tt.Fatalf(\"账号列表缺少凭证掩码信息: %s\", body)\n\t}\n}\n\nfunc TestPatchAccountCredentialsRemovesByUID(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"sk-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"sk-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"baseUrls\":[\"https://api.xiaomimimo.com/anthropic\"],\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPatch, \"/api/accounts/acct_test/credentials\", bytes.NewBufferString(`{\"removeCredentialUids\":[\"cred_b\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PATCH credentials status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 1 || len(channels[0].Upstream.APIKeys) != 1 || channels[0].Upstream.APIKeys[0] != \"sk-a\" {\n\t\tt.Fatalf(\"按 credentialUid 删除失败: %+v\", channels)\n\t}\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestProviderRouteNameAndPrimaryResult(t *testing.T) {\n\tbase := \"mimo-test\"\n\ttests := []struct {\n\t\troute config.ProviderRoute\n\t\twant string\n\t}{\n\t\t{route: config.ProviderRoute{ChannelKind: \"messages\"}, want: \"mimo-test-claude\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"chat\"}, want: \"mimo-test-chat\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"responses\"}, want: \"mimo-test-codex\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"gemini\"}, want: \"mimo-test-gemini\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := providerRouteName(base, tt.route, true); got != tt.want {\n\t\t\tt.Fatalf(\"providerRouteName(%s)=%q, want %q\", tt.route.ChannelKind, got, tt.want)\n\t\t}\n\t}\n\tif got := providerRouteName(base, config.ProviderRoute{ChannelKind: \"messages\"}, false); got != base {\n\t\tt.Fatalf(\"single route name=%q, want %q\", got, base)\n\t}\n\n\tresults := []AutoAddChannelResult{\n\t\t{ChannelKind: \"messages\", ChannelUID: \"ch_messages\", Index: 1},\n\t\t{ChannelKind: \"chat\", ChannelUID: \"ch_chat\", Index: 2},\n\t\t{ChannelKind: \"responses\", ChannelUID: \"ch_responses\", Index: 3},\n\t}\n\tprimary := primaryAutoAddResult(results, \"chat\")\n\tif primary.ChannelUID != \"ch_chat\" || primary.Index != 2 {\n\t\tt.Fatalf(\"primary=%+v, want chat result\", primary)\n\t}\n}\n\nfunc TestManagedAccountUIDForProviderUsesExistingAccount(t *testing.T) {\n\tcfg := config.Config{ManagedAccounts: []config.ManagedAccountConfig{\n\t\t{AccountUID: \"acct-old\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-new\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-deepseek\", ProviderID: \"deepseek\"},\n\t}}\n\tif got := managedAccountUIDForProvider(cfg, \"mimo\"); got != \"acct-new\" {\n\t\tt.Fatalf(\"mimo existing account = %q, want acct-new\", got)\n\t}\n\tif got := managedAccountUIDForProvider(cfg, \"unknown\"); got != \"\" {\n\t\tt.Fatalf(\"unknown provider 不应命中账号: %q\", got)\n\t}\n}\n\nfunc TestProviderAutoAddReusesExistingAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(`{\"providerId\":\"mimo\",\"apiKeys\":[\"sk-existing\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"追加已有 provider key status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 1 || channels[0].Upstream.ChannelUID != \"ch_messages\" {\n\t\tt.Fatalf(\"不应创建新渠道: %+v\", channels)\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.AccountUID != \"acct_test\" {\n\t\tt.Fatalf(\"响应未返回已有账号: body=%s err=%v\", w.Body.String(), err)\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}, "3bb1d97c8c88f000fb723bcb2be0f3cc38c31577": {"t": 1785759372.719154, "n": 10814, "h": "ac8030f57879db7e", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestListAccountsMasksCredentials(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-secret-value\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil)\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"sk-secret-value\") {\n\t\tt.Fatalf(\"账号列表泄露明文 Key: %s\", body)\n\t}\n\tif !strings.Contains(body, \"cred_test\") || !strings.Contains(body, \"keyMask\") {\n\t\tt.Fatalf(\"账号列表缺少凭证掩码信息: %s\", body)\n\t}\n}\n\nfunc TestPatchAccountCredentialsRemovesByUID(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"sk-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"sk-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"baseUrls\":[\"https://api.xiaomimimo.com/anthropic\"],\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPatch, \"/api/accounts/acct_test/credentials\", bytes.NewBufferString(`{\"removeCredentialUids\":[\"cred_b\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PATCH credentials status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 1 || len(channels[0].Upstream.APIKeys) != 1 || channels[0].Upstream.APIKeys[0] != \"sk-a\" {\n\t\tt.Fatalf(\"按 credentialUid 删除失败: %+v\", channels)\n\t}\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestProviderRouteNameAndPrimaryResult(t *testing.T) {\n\tbase := \"mimo-test\"\n\ttests := []struct {\n\t\troute config.ProviderRoute\n\t\twant string\n\t}{\n\t\t{route: config.ProviderRoute{ChannelKind: \"messages\"}, want: \"mimo-test-claude\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"chat\"}, want: \"mimo-test-chat\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"responses\"}, want: \"mimo-test-codex\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"gemini\"}, want: \"mimo-test-gemini\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := providerRouteName(base, tt.route, true); got != tt.want {\n\t\t\tt.Fatalf(\"providerRouteName(%s)=%q, want %q\", tt.route.ChannelKind, got, tt.want)\n\t\t}\n\t}\n\tif got := providerRouteName(base, config.ProviderRoute{ChannelKind: \"messages\"}, false); got != base {\n\t\tt.Fatalf(\"single route name=%q, want %q\", got, base)\n\t}\n\n\tresults := []AutoAddChannelResult{\n\t\t{ChannelKind: \"messages\", ChannelUID: \"ch_messages\", Index: 1},\n\t\t{ChannelKind: \"chat\", ChannelUID: \"ch_chat\", Index: 2},\n\t\t{ChannelKind: \"responses\", ChannelUID: \"ch_responses\", Index: 3},\n\t}\n\tprimary := primaryAutoAddResult(results, \"chat\")\n\tif primary.ChannelUID != \"ch_chat\" || primary.Index != 2 {\n\t\tt.Fatalf(\"primary=%+v, want chat result\", primary)\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}, "94e63e2e18901a422453208b08f2bfec7a07dd6e": {"t": 1785759374.194954, "n": 8961, "h": "fa5698b803ebe25b", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestListAccountsMasksCredentials(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-secret-value\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil)\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"sk-secret-value\") {\n\t\tt.Fatalf(\"账号列表泄露明文 Key: %s\", body)\n\t}\n\tif !strings.Contains(body, \"cred_test\") || !strings.Contains(body, \"keyMask\") {\n\t\tt.Fatalf(\"账号列表缺少凭证掩码信息: %s\", body)\n\t}\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestProviderRouteNameAndPrimaryResult(t *testing.T) {\n\tbase := \"mimo-test\"\n\ttests := []struct {\n\t\troute config.ProviderRoute\n\t\twant string\n\t}{\n\t\t{route: config.ProviderRoute{ChannelKind: \"messages\"}, want: \"mimo-test-claude\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"chat\"}, want: \"mimo-test-chat\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"responses\"}, want: \"mimo-test-codex\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"gemini\"}, want: \"mimo-test-gemini\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := providerRouteName(base, tt.route, true); got != tt.want {\n\t\t\tt.Fatalf(\"providerRouteName(%s)=%q, want %q\", tt.route.ChannelKind, got, tt.want)\n\t\t}\n\t}\n\tif got := providerRouteName(base, config.ProviderRoute{ChannelKind: \"messages\"}, false); got != base {\n\t\tt.Fatalf(\"single route name=%q, want %q\", got, base)\n\t}\n\n\tresults := []AutoAddChannelResult{\n\t\t{ChannelKind: \"messages\", ChannelUID: \"ch_messages\", Index: 1},\n\t\t{ChannelKind: \"chat\", ChannelUID: \"ch_chat\", Index: 2},\n\t\t{ChannelKind: \"responses\", ChannelUID: \"ch_responses\", Index: 3},\n\t}\n\tprimary := primaryAutoAddResult(results, \"chat\")\n\tif primary.ChannelUID != \"ch_chat\" || primary.Index != 2 {\n\t\tt.Fatalf(\"primary=%+v, want chat result\", primary)\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}, "c3d5e4e76b9c81aa18fad112aebcf703968eb012": {"t": 1785759375.5814693, "n": 7362, "h": "b0b00f99cc1109f4", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestProviderRouteNameAndPrimaryResult(t *testing.T) {\n\tbase := \"mimo-test\"\n\ttests := []struct {\n\t\troute config.ProviderRoute\n\t\twant string\n\t}{\n\t\t{route: config.ProviderRoute{ChannelKind: \"messages\"}, want: \"mimo-test-claude\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"chat\"}, want: \"mimo-test-chat\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"responses\"}, want: \"mimo-test-codex\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"gemini\"}, want: \"mimo-test-gemini\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := providerRouteName(base, tt.route, true); got != tt.want {\n\t\t\tt.Fatalf(\"providerRouteName(%s)=%q, want %q\", tt.route.ChannelKind, got, tt.want)\n\t\t}\n\t}\n\tif got := providerRouteName(base, config.ProviderRoute{ChannelKind: \"messages\"}, false); got != base {\n\t\tt.Fatalf(\"single route name=%q, want %q\", got, base)\n\t}\n\n\tresults := []AutoAddChannelResult{\n\t\t{ChannelKind: \"messages\", ChannelUID: \"ch_messages\", Index: 1},\n\t\t{ChannelKind: \"chat\", ChannelUID: \"ch_chat\", Index: 2},\n\t\t{ChannelKind: \"responses\", ChannelUID: \"ch_responses\", Index: 3},\n\t}\n\tprimary := primaryAutoAddResult(results, \"chat\")\n\tif primary.ChannelUID != \"ch_chat\" || primary.Index != 2 {\n\t\tt.Fatalf(\"primary=%+v, want chat result\", primary)\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}, "5cfccf226a320c70c7da2c8c594bc5980bba37a0": {"t": 1785759376.7509387, "n": 7280, "h": "eda48876eafa95ab", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestProviderRouteNameAndPrimaryResult(t *testing.T) {\n\tbase := \"mimo-test\"\n\ttests := []struct {\n\t\troute config.ProviderRoute\n\t\twant string\n\t}{\n\t\t{route: config.ProviderRoute{ChannelKind: \"messages\"}, want: \"mimo-test-claude\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"chat\"}, want: \"mimo-test-chat\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"responses\"}, want: \"mimo-test-codex\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := providerRouteName(base, tt.route, true); got != tt.want {\n\t\t\tt.Fatalf(\"providerRouteName(%s)=%q, want %q\", tt.route.ChannelKind, got, tt.want)\n\t\t}\n\t}\n\tif got := providerRouteName(base, config.ProviderRoute{ChannelKind: \"messages\"}, false); got != base {\n\t\tt.Fatalf(\"single route name=%q, want %q\", got, base)\n\t}\n\n\tresults := []AutoAddChannelResult{\n\t\t{ChannelKind: \"messages\", ChannelUID: \"ch_messages\", Index: 1},\n\t\t{ChannelKind: \"chat\", ChannelUID: \"ch_chat\", Index: 2},\n\t\t{ChannelKind: \"responses\", ChannelUID: \"ch_responses\", Index: 3},\n\t}\n\tprimary := primaryAutoAddResult(results, \"chat\")\n\tif primary.ChannelUID != \"ch_chat\" || primary.Index != 2 {\n\t\tt.Fatalf(\"primary=%+v, want chat result\", primary)\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}, "f9cd617405e5bd0711e6f9b2839b149c255abc8b": {"t": 1785759378.094776, "n": 6082, "h": "ba629d11d61678ab", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}, "7501738b5f6704495a82ecb6e4cfc045724af381": {"t": 1785759379.63323, "n": 5980, "h": "ba130fb90ede0a02", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusBadRequest {\n\t\tt.Fatalf(\"期望 400, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusBadRequest {\n\t\tt.Fatalf(\"期望 400, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusBadRequest {\n\t\tt.Fatalf(\"期望 400, 实际=%d\", w.Code)\n\t}\n}\n"}, "4da6d77e15a5c2ac4bc15ce1ad1a68cfdbd2e2d0": {"t": 1785846933.008904, "n": 68677, "h": "9a46edf960bd44dc", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/BenedictKing/ccx/internal/errutil\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestListAccountsMasksCredentials(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-secret-value\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil)\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"sk-secret-value\") {\n\t\tt.Fatalf(\"账号列表泄露明文 Key: %s\", body)\n\t}\n\tif !strings.Contains(body, \"cred_test\") || !strings.Contains(body, \"keyMask\") {\n\t\tt.Fatalf(\"账号列表缺少凭证掩码信息: %s\", body)\n\t}\n}\n\nfunc TestListAccountsIncludesActiveEndpointModelAvailability(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"volcengine\",\"name\":\"volcengine\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"ark-secret-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"ark-secret-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"volcengine\",\"name\":\"volcengine-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://example.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tstore, err := NewProfileStoreWithDB(newTestDB(t))\n\tif err != nil {\n\t\tt.Fatalf(\"NewProfileStoreWithDB 失败: %v\", err)\n\t}\n\tupdatedAt := time.Date(2026, 7, 17, 10, 54, 33, 0, time.UTC)\n\tdiscoveredAt := updatedAt\n\tdiscoveredAtLater := updatedAt.Add(time.Minute)\n\tfor _, profile := range []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_a\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_a\", KeyMask: \"ark-a***001\", AvailableModels: []string{\"model-b\", \"model-a\"}, UpdatedAt: updatedAt,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI, ModelsDiscoveredAt: &discoveredAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_b\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_b\", KeyMask: \"ark-b***002\", AvailableModels: []string{\"model-c\", \"model-b\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI, ModelsDiscoveredAt: &discoveredAtLater,\n\t\t},\n\t} {\n\t\tif err := store.Upsert(profile); err != nil {\n\t\t\tt.Fatalf(\"ProfileStore.Upsert 失败: %v\", err)\n\t\t}\n\t}\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager, Runner: runner})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response struct {\n\t\tAccounts []managedAccountView `json:\"accounts\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatalf(\"解析响应失败: %v\", err)\n\t}\n\tif len(response.Accounts) != 1 || len(response.Accounts[0].Channels) != 1 {\n\t\tt.Fatalf(\"账号渠道数量错误: %+v\", response.Accounts)\n\t}\n\tchannel := response.Accounts[0].Channels[0]\n\tif got, want := strings.Join(channel.DiscoveredModels, \",\"), \"model-a,model-b,model-c\"; got != want {\n\t\tt.Fatalf(\"DiscoveredModels=%q, want %q\", got, want)\n\t}\n\tif !channel.ModelInventoryKnown {\n\t\tt.Fatal(\"已发现模型时 ModelInventoryKnown 应为 true\")\n\t}\n\tif len(channel.ModelBindings) != 2 {\n\t\tt.Fatalf(\"ModelBindings=%+v, want 2\", channel.ModelBindings)\n\t}\n\tif got := strings.Join(channel.ModelBindings[0].Models, \",\"); got != \"model-a,model-b\" {\n\t\tt.Fatalf(\"第一个 Key 模型=%q\", got)\n\t}\n\tif channel.ModelsUpdatedAt == nil || !channel.ModelsUpdatedAt.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"ModelsUpdatedAt=%v\", channel.ModelsUpdatedAt)\n\t}\n\tif strings.Contains(w.Body.String(), \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", w.Body.String())\n\t}\n}\n\nfunc TestListAccountsIncludesCustomAutoManagedProtocolAvailability(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [],\n \"upstream\": [], \"chatUpstream\": [],\n \"responsesUpstream\": [{\"accountUid\":\"acct_custom\",\"channelUid\":\"ch_custom\",\"name\":\"custom-endpoint\",\"serviceType\":\"responses\",\"baseUrl\":\"https://example.com\",\"apiKeys\":[\"sk-custom-secret\"],\"apiKeyConfigs\":[{\"key\":\"sk-custom-secret\",\"credentialUid\":\"cred_custom\",\"baseUrl\":\"https://example.com\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tstore, err := NewProfileStoreWithDB(newTestDB(t))\n\tif err != nil {\n\t\tt.Fatalf(\"NewProfileStoreWithDB 失败: %v\", err)\n\t}\n\tdiscoveredAt := time.Date(2026, 7, 25, 5, 11, 29, 0, time.UTC)\n\tprofile := &KeyEndpointProfile{\n\t\tEndpointUID: \"ep_custom\", AccountUID: \"acct_custom\", ChannelUID: \"ch_custom\",\n\t\tCredentialUID: \"cred_custom\", KeyMask: \"sk-c***ret\", ServiceType: \"responses\",\n\t\tAvailableModels: []string{\"gpt-5.6-sol\"}, ModelsDiscoveredAt: &discoveredAt,\n\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI,\n\t\tProtocolModels: map[string][]string{\n\t\t\t\"messages\": {\"gpt-5.6-sol\"},\n\t\t\t\"chat\": {\"gpt-5.6-sol\"},\n\t\t\t\"responses\": {\"gpt-5.6-sol\"},\n\t\t},\n\t\tProtocolDiscoveredAt: map[string]time.Time{\n\t\t\t\"messages\": discoveredAt, \"chat\": discoveredAt, \"responses\": discoveredAt,\n\t\t},\n\t\tProtocolDiscoverySource: map[string]string{\n\t\t\t\"messages\": \"protocol_probe\", \"chat\": \"protocol_probe\", \"responses\": \"models_api\",\n\t\t},\n\t\tUpdatedAt: discoveredAt,\n\t}\n\tif err := store.Upsert(profile); err != nil {\n\t\tt.Fatalf(\"ProfileStore.Upsert 失败: %v\", err)\n\t}\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: cfgManager,\n\t\tRunner: NewAutoDiscoveryRunner(store, nil),\n\t})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tif strings.Contains(w.Body.String(), \"sk-custom-secret\") {\n\t\tt.Fatalf(\"账号列表泄露自定义渠道明文 Key: %s\", w.Body.String())\n\t}\n\n\tvar response struct {\n\t\tAccounts []managedAccountView `json:\"accounts\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatalf(\"解析响应失败: %v\", err)\n\t}\n\tif len(response.Accounts) != 1 {\n\t\tt.Fatalf(\"Accounts=%+v, want 1\", response.Accounts)\n\t}\n\taccount := response.Accounts[0]\n\tif account.AccountUID != \"acct_custom\" || len(account.Credentials) != 1 || len(account.Channels) != 1 {\n\t\tt.Fatalf(\"自定义自动托管账号聚合错误: %+v\", account)\n\t}\n\tprotocols := make([]string, 0, len(account.Channels[0].ProtocolAvailability))\n\tfor _, availability := range account.Channels[0].ProtocolAvailability {\n\t\tprotocols = append(protocols, availability.Protocol)\n\t}\n\tif got, want := strings.Join(protocols, \",\"), \"messages,chat,responses\"; got != want {\n\t\tt.Fatalf(\"ProtocolAvailability=%q, want %q\", got, want)\n\t}\n}\n\nfunc TestManagedChannelModelAvailabilityIncludesEmptyAutoDiscoveryResult(t *testing.T) {\n\tupdatedAt := time.Date(2026, 7, 17, 11, 2, 0, 0, time.UTC)\n\tmodels, bindings, latest, known := managedChannelModelAvailability([]*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_empty\", CredentialUID: \"cred_empty\", KeyMask: \"ark-e***001\",\n\t\t\tSource: \"auto_discovery\", AvailableModels: []string{}, UpdatedAt: updatedAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_models\", CredentialUID: \"cred_models\", KeyMask: \"ark-m***002\",\n\t\t\tAvailableModels: []string{\"model-a\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_unknown\", CredentialUID: \"cred_unknown\", KeyMask: \"ark-u***003\",\n\t\t\tSource: \"l1_passive\", UpdatedAt: updatedAt.Add(2 * time.Minute),\n\t\t},\n\t})\n\n\tif !known {\n\t\tt.Fatal(\"自动发现的空模型清单仍应标记为已知\")\n\t}\n\tif got, want := strings.Join(models, \",\"), \"model-a\"; got != want {\n\t\tt.Fatalf(\"models=%q, want %q\", got, want)\n\t}\n\tif len(bindings) != 2 {\n\t\tt.Fatalf(\"bindings=%+v, want only two known inventories\", bindings)\n\t}\n\tif got := bindings[0]; got.KeyMask != \"ark-e***001\" || len(got.Models) != 0 {\n\t\tt.Fatalf(\"empty discovery binding=%+v, want empty ark-e binding\", got)\n\t}\n\tif latest == nil || !latest.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"latest=%v, want discovered inventory update time\", latest)\n\t}\n}\n\nfunc TestManagedChannelModelAvailabilityIncludesDiscoveryMetadata(t *testing.T) {\n\tfirst := time.Date(2026, 7, 22, 0, 42, 12, 0, time.UTC)\n\tsecond := first.Add(time.Minute)\n\tinventory := managedChannelModelAvailabilityDetails([]*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep-a\",\n\t\t\tCredentialUID: \"cred-a\",\n\t\t\tKeyMask: \"ark-a***001\",\n\t\t\tAvailableModels: []string{\"glm-5.2\"},\n\t\t\tUpdatedAt: first,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceControlPlane,\n\t\t\tModelDiscoveryMessage: \"火山管控面 Coding Plan 模型清单\",\n\t\t\tModelsDiscoveredAt: &first,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep-b\",\n\t\t\tCredentialUID: \"cred-b\",\n\t\t\tKeyMask: \"ark-b***002\",\n\t\t\tAvailableModels: []string{\"deepseek-v4-pro\"},\n\t\t\tUpdatedAt: second,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceBuiltinFallback,\n\t\t\tModelDiscoveryMessage: \"管控面失败,已回退内置模型清单\",\n\t\t\tModelsDiscoveredAt: &second,\n\t\t},\n\t})\n\n\tif !inventory.known || inventory.source != \"mixed\" {\n\t\tt.Fatalf(\"inventory source=%q known=%v\", inventory.source, inventory.known)\n\t}\n\tif !inventory.latestDiscoveredAt.Equal(second) || inventory.message != \"管控面失败,已回退内置模型清单\" {\n\t\tt.Fatalf(\"inventory discovery metadata=%+v\", inventory)\n\t}\n\tif len(inventory.bindings) != 2 || inventory.bindings[1].ModelsDiscoveredAt == nil {\n\t\tt.Fatalf(\"binding discovery metadata=%+v\", inventory.bindings)\n\t}\n}\n\nfunc TestManagedProtocolAvailabilityViewsAggregatesByProtocolAndCredential(t *testing.T) {\n\tdiscoveredAt := time.Date(2026, 7, 25, 10, 0, 0, 0, time.UTC)\n\tprofiles := []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep-a\", CredentialUID: \"cred-a\", KeyMask: \"sk-a***001\", ServiceType: \"responses\",\n\t\t\tUpdatedAt: discoveredAt, ProtocolModels: map[string][]string{\n\t\t\t\t\"responses\": {\"shared\", \"responses-only\"},\n\t\t\t\t\"chat\": {\"shared\"},\n\t\t\t},\n\t\t\tProtocolDiscoveredAt: map[string]time.Time{\"responses\": discoveredAt, \"chat\": discoveredAt},\n\t\t\tProtocolDiscoverySource: map[string]string{\"responses\": \"models_api\", \"chat\": \"protocol_probe\"},\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep-b\", CredentialUID: \"cred-b\", KeyMask: \"sk-b***002\", ServiceType: \"responses\",\n\t\t\tUpdatedAt: discoveredAt, ProtocolModels: map[string][]string{\n\t\t\t\t\"responses\": {\"shared\"},\n\t\t\t\t\"messages\": {\"shared\", \"messages-only\"},\n\t\t\t},\n\t\t\tProtocolDiscoveredAt: map[string]time.Time{\"responses\": discoveredAt, \"messages\": discoveredAt},\n\t\t\tProtocolDiscoverySource: map[string]string{\"responses\": \"models_api\", \"messages\": \"protocol_probe\"},\n\t\t},\n\t}\n\n\tviews := managedProtocolAvailabilityViews(profiles)\n\tif len(views) != 3 {\n\t\tt.Fatalf(\"views=%+v\", views)\n\t}\n\tif views[0].Protocol != \"messages\" || strings.Join(views[0].DiscoveredModels, \",\") != \"messages-only,shared\" {\n\t\tt.Fatalf(\"messages view=%+v\", views[0])\n\t}\n\tif views[2].Protocol != \"responses\" || len(views[2].ModelBindings) != 2 {\n\t\tt.Fatalf(\"responses view=%+v\", views[2])\n\t}\n}\n\nfunc TestPatchAccountCredentialsRemovesByUID(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"sk-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"sk-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"baseUrls\":[\"https://api.xiaomimimo.com/anthropic\"],\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPatch, \"/api/accounts/acct_test/credentials\", bytes.NewBufferString(`{\"removeCredentialUids\":[\"cred_b\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PATCH credentials status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 1 || len(channels[0].Upstream.APIKeys) != 1 || channels[0].Upstream.APIKeys[0] != \"sk-a\" {\n\t\tt.Fatalf(\"按 credentialUid 删除失败: %+v\", channels)\n\t}\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestInferAutoAddProviderID(t *testing.T) {\n\tzhipuKey := \"0123456789abcdef0123456789abcdef.ABCDEFGHIJKLMNO1\"\n\ttests := []struct {\n\t\tname string\n\t\tbaseURLs []string\n\t\tapiKeys []string\n\t\twant string\n\t}{\n\t\t{name: \"官方 Claude URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"官方 OpenAI URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4/\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"两个官方协议 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\", \"https://open.bigmodel.cn/api/paas/v4\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"仅智谱 Key\", apiKeys: []string{zhipuKey}, want: \"glm\"},\n\t\t{name: \"第三方 URL 不按 Key 提升\", baseURLs: []string{\"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"混合官方与第三方 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4\", \"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"共享 sk Key 无法推断\", apiKeys: []string{\"sk-abcdefghijklmnopqrstuvwxyz123456\"}},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := inferAutoAddProviderID(tt.baseURLs, tt.apiKeys); got != tt.want {\n\t\t\t\tt.Fatalf(\"inferAutoAddProviderID() = %q, want %q\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestApplyProviderUpstreamDefaults(t *testing.T) {\n\t// shouldPassbackReasoningContentByDefault 按 ProviderID+ServiceType 静态推导(GLM 已知\n\t// 协议事实),ApplyProviderUpstreamDefaults 不再写字段,测试必须设置 ProviderID 才能命中。\n\tglmChat := config.UpstreamConfig{ProviderID: \"glm\", ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmChat)\n\tif glmChat.ReasoningParamStyle != \"reasoning_effort\" || !glmChat.IsPassbackReasoningContentEnabled() {\n\t\tt.Fatalf(\"GLM OpenAI 默认兼容参数未补齐: %+v\", glmChat)\n\t}\n\n\tglmClaude := config.UpstreamConfig{ProviderID: \"glm\", ServiceType: \"claude\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmClaude)\n\tif glmClaude.ReasoningParamStyle != \"\" || glmClaude.IsPassbackReasoningContentEnabled() {\n\t\tt.Fatalf(\"GLM Claude 原生 route 不应注入 OpenAI 参数: %+v\", glmClaude)\n\t}\n\n\tcustom := config.UpstreamConfig{ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"\", &custom)\n\tif custom.ReasoningParamStyle != \"\" || custom.IsPassbackReasoningContentEnabled() {\n\t\tt.Fatalf(\"自定义渠道不应应用 GLM 默认值: %+v\", custom)\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCustomAutoAddResponseIncludesActualRoute(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\tdiscoverer := NewRateLimitDiscoverer(RateLimitDiscovererConfig{PassiveAimdEnabled: true, QuietLogs: true})\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, RateLimitDiscoverer: discoverer})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"fastaitoken-com-test\",\"baseUrls\":[\"https://example.com\"],\"apiKeys\":[\"sk-test\"],\"rateLimitHint\":{\"initialRpm\":30,\"effectiveRpm\":15,\"rateLimited\":true,\"rateLimitedCount\":1}}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 {\n\t\tt.Fatalf(\"channels=%+v, want one actual route\", response.Channels)\n\t}\n\troute := response.Channels[0]\n\tif route.ChannelKind != \"responses\" || route.ServiceType != \"responses\" || route.ChannelUID != response.ChannelUID {\n\t\tt.Fatalf(\"route=%+v response=%+v\", route, response)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.ResponsesUpstream) != 1 || len(cfg.Upstream) != 0 {\n\t\tt.Fatalf(\"custom route persisted in wrong channel: responses=%d messages=%d\", len(cfg.ResponsesUpstream), len(cfg.Upstream))\n\t}\n\tcreated := cfg.ResponsesUpstream[0]\n\tbaseURL := created.GetEffectiveBaseURL()\n\tendpointUID := GenerateEndpointUID(created.ChannelUID, baseURL, KeyHashFromAPIKey(\"sk-test\"))\n\tif suggestion := discoverer.SuggestedLimit(endpointUID); suggestion.RPM != 15 || suggestion.Confidence != 0.7 {\n\t\tt.Fatalf(\"rate limit suggestion=%+v, want rpm=15 confidence=0.7\", suggestion)\n\t}\n\n\tstore := newTestProfileStore(t)\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\tseedAutoAddRateLimitHint(\n\t\t&AutoManagedDeps{CfgManager: manager, Runner: runner, RateLimitDiscoverer: discoverer},\n\t\tmanager.GetAccountChannels(response.AccountUID),\n\t\t&AutoAddRateLimitHint{InitialRPM: 30, EffectiveRPM: 15, RateLimited: true, RateLimitedCount: 1},\n\t\t\"sk-test\",\n\t\t\"https://example.com\",\n\t)\n\tprofile := store.Get(endpointUID)\n\tif profile == nil || profile.DiscoveredRPM != 15 || profile.RateLimitConfidence != 0.7 || profile.RateLimitSource != string(RateLimitSourcePassiveAIMD) {\n\t\tt.Fatalf(\"rate limit profile=%+v\", profile)\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyToEquivalentExistingBaseURL(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [],\n \"chatUpstream\": [{\"channelUid\":\"ch_existing\",\"name\":\"localhost-old-name\",\"serviceType\":\"openai\",\"baseUrl\":\"http://localhost:8990/v1\",\"apiKeys\":[\"sk-old\"],\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"localhost-8990\",\"baseUrls\":[\"http://localhost:8990/\"],\"apiKeys\":[\"sk-new\"]}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 || response.Channels[0].Name != \"localhost-old-name\" {\n\t\tt.Fatalf(\"应返回已有渠道: %+v\", response.Channels)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 0 || len(cfg.ChatUpstream) != 1 {\n\t\tt.Fatalf(\"重复请求不应创建新渠道: messages=%d chat=%d\", len(cfg.Upstream), len(cfg.ChatUpstream))\n\t}\n\tif got := strings.Join(cfg.ChatUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"已有渠道密钥=%q, want sk-old,sk-new\", got)\n\t}\n}\n\nfunc TestFindExistingAutoAddChannelsPreservesHashSemantics(t *testing.T) {\n\tcfg := config.Config{\n\t\tChatUpstream: []config.UpstreamConfig{{\n\t\t\tChannelUID: \"ch-hash\", ServiceType: \"openai\", BaseURL: \"https://example.com#\",\n\t\t}},\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com\"}); len(matches) != 0 {\n\t\tt.Fatalf(\"普通 Base URL 不应匹配禁止版本前缀的地址: %+v\", matches)\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com/#\"}); len(matches) != 1 {\n\t\tt.Fatalf(\"等效 # 地址匹配数量=%d, want 1\", len(matches))\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyAndMissingRouteToManagedAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [{\"accountUid\":\"acct_existing\",\"channelUid\":\"ch_existing\",\"name\":\"localhost-8990\",\"serviceType\":\"claude\",\"baseUrl\":\"http://localhost:8990\",\"apiKeys\":[\"sk-old\"],\"autoManaged\":true,\"status\":\"active\"}],\n \"chatUpstream\": [], \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"localhost-8990\",\n \"baseUrls\":[\"http://localhost:8990/v1\"],\n \"apiKeys\":[\"sk-new\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"claude-test\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"gpt-test\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d\", len(cfg.Upstream), len(cfg.ResponsesUpstream))\n\t}\n\tif cfg.Upstream[0].AccountUID != \"acct_existing\" || cfg.ResponsesUpstream[0].AccountUID != \"acct_existing\" {\n\t\tt.Fatalf(\"应复用已有账号: messages=%q responses=%q\", cfg.Upstream[0].AccountUID, cfg.ResponsesUpstream[0].AccountUID)\n\t}\n\tif got := strings.Join(cfg.Upstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"messages 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif got := strings.Join(cfg.ResponsesUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"responses 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif cfg.Upstream[0].Name != \"localhost-8990-claude\" || cfg.ResponsesUpstream[0].Name != \"localhost-8990-codex\" {\n\t\tt.Fatalf(\"多协议自动命名错误: messages=%q responses=%q\", cfg.Upstream[0].Name, cfg.ResponsesUpstream[0].Name)\n\t}\n}\n\nfunc TestCustomAutoAddCreatesAllDetectedRoutesWithProtocolModels(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"fastaitoken-com-test\",\n \"baseUrls\":[\"https://example.com/keys\"],\n \"apiKeys\":[\"sk-test\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"shared\",\"messages-only\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"shared\",\"responses-only\"]},\n {\"channelKind\":\"chat\",\"supportedModels\":[\"shared\",\"chat-only\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 3 {\n\t\tt.Fatalf(\"channels=%+v, want three detected routes\", response.Channels)\n\t}\n\tfor _, route := range response.Channels {\n\t\tif route.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route account=%q, want %q\", route.AccountUID, response.AccountUID)\n\t\t}\n\t}\n\tif response.Channels[1].ChannelKind != \"responses\" || response.ChannelUID != response.Channels[1].ChannelUID {\n\t\tt.Fatalf(\"请求协议应作为主路由: response=%+v\", response)\n\t}\n\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 || len(cfg.ChatUpstream) != 1 || len(cfg.GeminiUpstream) != 0 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d chat=%d gemini=%d\",\n\t\t\tlen(cfg.Upstream), len(cfg.ResponsesUpstream), len(cfg.ChatUpstream), len(cfg.GeminiUpstream))\n\t}\n\tchecks := []struct {\n\t\tchannel config.UpstreamConfig\n\t\tname string\n\t\tmodels []string\n\t}{\n\t\t{cfg.Upstream[0], \"fastaitoken-com-test-claude\", []string{\"shared\", \"messages-only\"}},\n\t\t{cfg.ResponsesUpstream[0], \"fastaitoken-com-test-codex\", []string{\"shared\", \"responses-only\"}},\n\t\t{cfg.ChatUpstream[0], \"fastaitoken-com-test-chat\", []string{\"shared\", \"chat-only\"}},\n\t}\n\tfor _, check := range checks {\n\t\tif check.channel.Name != check.name || check.channel.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route identity=%+v, want name=%q account=%q\", check.channel, check.name, response.AccountUID)\n\t\t}\n\t\tif strings.Join(check.channel.SupportedModels, \",\") != strings.Join(check.models, \",\") {\n\t\t\tt.Fatalf(\"route %s models=%v, want %v\", check.name, check.channel.SupportedModels, check.models)\n\t\t}\n\t}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesRenamesAndSyncsCredentials(t *testing.T) {\n\tchannels := []config.AccountChannel{\n\t\t{Kind: \"messages\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-messages\", Name: \"old-claude\",\n\t\t\tServiceType: \"claude\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t\t{Kind: \"responses\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-responses\", Name: \"old-codex\",\n\t\t\tServiceType: \"responses\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t}\n\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\tName: \"renamed\", APIKeys: []string{\"sk-old\", \"sk-new\"},\n\t}, channels, len(channels))\n\tif err != nil || status != http.StatusOK {\n\t\tt.Fatalf(\"status=%d err=%v\", status, err)\n\t}\n\tif len(updates) != 2 || updates[0].Name != \"renamed-claude\" || updates[1].Name != \"renamed-codex\" {\n\t\tt.Fatalf(\"route names=%+v\", updates)\n\t}\n\tfor _, update := range updates {\n\t\tif len(update.APIKeyConfig) != 2 || update.APIKeyConfig[1].BaseURL != \"https://example.com\" {\n\t\t\tt.Fatalf(\"route credentials=%+v\", update.APIKeyConfig)\n\t\t}\n\t\tif update.APIKeyConfig[1].CredentialUID == \"\" {\n\t\t\tt.Fatalf(\"new credential missing stable uid: %+v\", update.APIKeyConfig[1])\n\t\t}\n\t}\n}\n\n// newCustomManagedChannel 构造一条自定义自动托管渠道,用于地址池编辑链路的表驱动断言。\nfunc newCustomManagedChannel(kind, serviceType string, baseURLs []string, keyConfigs []config.APIKeyConfig) config.AccountChannel {\n\tapiKeys := make([]string, 0, len(keyConfigs))\n\tfor _, keyConfig := range keyConfigs {\n\t\tapiKeys = append(apiKeys, keyConfig.Key)\n\t}\n\tprimary := \"\"\n\tif len(baseURLs) > 0 {\n\t\tprimary = baseURLs[0]\n\t}\n\treturn config.AccountChannel{Kind: kind, Upstream: config.UpstreamConfig{\n\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-\" + kind, Name: \"custom\" + accountRouteSuffix(kind),\n\t\tServiceType: serviceType, AutoManaged: true, BaseURL: primary,\n\t\tBaseURLs: append([]string(nil), baseURLs...), APIKeys: apiKeys,\n\t\tAPIKeyConfigs: append([]config.APIKeyConfig(nil), keyConfigs...),\n\t}}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesBaseURLPool(t *testing.T) {\n\tconst (\n\t\turlA = \"https://a.example.com\"\n\t\turlB = \"https://b.example.com\"\n\t\turlC = \"https://c.example.com\"\n\t)\n\ttests := []struct {\n\t\tname string\n\t\t// existingURLs / existingKeys 描述渠道现状,requestURLs 为请求里的 baseUrls(nil 表示未提供)\n\t\texistingURLs []string\n\t\texistingKeys []config.APIKeyConfig\n\t\tapiKeys []string\n\t\trequestURLs []string\n\t\twantStatus int\n\t\twantErr bool\n\t\twantBaseURLs []string\n\t\t// wantBindings 与 apiKeys 顺序一致,空串表示该 Key 未绑定端点\n\t\twantBindings []string\n\t}{\n\t\t{\n\t\t\tname: \"单地址扩为两地址\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA, urlB},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlB},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"两地址删除其中一个且保留有效绑定\",\n\t\t\texistingURLs: []string{urlA, urlB},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"地址顺序变化改变首地址\",\n\t\t\texistingURLs: []string{urlA, urlB},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlB, urlA},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlB, urlA},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"尾部斜杠与默认版本前缀等价地址去重\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA, urlA + \"/\", urlA + \"/v1\", \" \" + urlB + \"/ \", urlB},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlB},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"全部非法地址被拒绝\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{\"\", \" \", \"/\"},\n\t\t\twantStatus: http.StatusBadRequest,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"未提供地址时沿用渠道现有地址\",\n\t\t\texistingURLs: []string{urlA, urlB},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlB}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: nil,\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlB},\n\t\t\twantBindings: []string{urlB},\n\t\t},\n\t\t{\n\t\t\tname: \"绑定指向已删除地址时迁移到新首地址\",\n\t\t\texistingURLs: []string{urlA, urlB},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlB}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA, urlC},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlC},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"原本未绑定的凭证保持未绑定\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\"}, {Key: \"sk-2\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\", \"sk-2\", \"sk-new\"},\n\t\t\trequestURLs: []string{urlB, urlA},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlB, urlA},\n\t\t\t// sk-1 未绑定保持为空;sk-2 绑定仍在池内保持不变;新增 sk-new 绑定首地址\n\t\t\twantBindings: []string{\"\", urlA, urlB},\n\t\t},\n\t\t{\n\t\t\tname: \"非规范绑定被折叠为 canonical 形式\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA + \"/v1/\"}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA, urlB},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlB},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tchannels := []config.AccountChannel{\n\t\t\t\tnewCustomManagedChannel(\"messages\", \"claude\", tt.existingURLs, tt.existingKeys),\n\t\t\t}\n\t\t\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\t\t\tName: \"custom\", APIKeys: tt.apiKeys, BaseURLs: tt.requestURLs,\n\t\t\t}, channels, len(channels))\n\t\t\tif status != tt.wantStatus || (err != nil) != tt.wantErr {\n\t\t\t\tt.Fatalf(\"status=%d err=%v, want status=%d err=%v\", status, err, tt.wantStatus, tt.wantErr)\n\t\t\t}\n\t\t\tif tt.wantErr {\n\t\t\t\tif len(updates) != 0 {\n\t\t\t\t\tt.Fatalf(\"非法输入不应产生渠道更新: %+v\", updates)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(updates) != 1 {\n\t\t\t\tt.Fatalf(\"updates=%+v, want 1\", updates)\n\t\t\t}\n\t\t\tupdate := updates[0]\n\t\t\tif strings.Join(update.BaseURLs, \",\") != strings.Join(tt.wantBaseURLs, \",\") {\n\t\t\t\tt.Fatalf(\"BaseURLs=%v, want %v\", update.BaseURLs, tt.wantBaseURLs)\n\t\t\t}\n\t\t\tif len(update.APIKeyConfig) != len(tt.wantBindings) {\n\t\t\t\tt.Fatalf(\"APIKeyConfig=%+v, want %d 条\", update.APIKeyConfig, len(tt.wantBindings))\n\t\t\t}\n\t\t\tfor i, keyConfig := range update.APIKeyConfig {\n\t\t\t\tif keyConfig.Key != tt.apiKeys[i] {\n\t\t\t\t\tt.Fatalf(\"APIKeyConfig[%d].Key=%q, want %q\", i, keyConfig.Key, tt.apiKeys[i])\n\t\t\t\t}\n\t\t\t\tif keyConfig.BaseURL != tt.wantBindings[i] {\n\t\t\t\t\tt.Fatalf(\"APIKeyConfig[%d](%s).BaseURL=%q, want %q\", i, keyConfig.Key, keyConfig.BaseURL, tt.wantBindings[i])\n\t\t\t\t}\n\t\t\t\tif keyConfig.CredentialUID == \"\" {\n\t\t\t\t\tt.Fatalf(\"APIKeyConfig[%d] 缺少稳定 credentialUid: %+v\", i, keyConfig)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesSyncsBaseURLPoolAcrossProtocols(t *testing.T) {\n\tconst (\n\t\turlA = \"https://a.example.com\"\n\t\turlB = \"https://b.example.com\"\n\t)\n\texistingKeys := []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}}\n\tchannels := []config.AccountChannel{\n\t\tnewCustomManagedChannel(\"messages\", \"claude\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"chat\", \"openai\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"responses\", \"responses\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"gemini\", \"gemini\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"images\", \"openai\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"vectors\", \"openai\", []string{urlA}, existingKeys),\n\t}\n\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\tName: \"custom\", APIKeys: []string{\"sk-1\"}, BaseURLs: []string{urlB + \"/\", urlA},\n\t}, channels, len(channels))\n\tif err != nil || status != http.StatusOK {\n\t\tt.Fatalf(\"status=%d err=%v\", status, err)\n\t}\n\tif len(updates) != len(channels) {\n\t\tt.Fatalf(\"updates=%d, want %d\", len(updates), len(channels))\n\t}\n\twantPool := urlB + \",\" + urlA\n\tfor i, update := range updates {\n\t\tif got := strings.Join(update.BaseURLs, \",\"); got != wantPool {\n\t\t\tt.Fatalf(\"%s 渠道地址池=%q, want %q\", channels[i].Kind, got, wantPool)\n\t\t}\n\t\tif len(update.APIKeyConfig) != 1 || update.APIKeyConfig[0].BaseURL != urlA {\n\t\t\tt.Fatalf(\"%s 渠道凭证绑定=%+v, want %s\", channels[i].Kind, update.APIKeyConfig, urlA)\n\t\t}\n\t}\n}\n\nfunc TestUpdateAccountIgnoresBaseURLsForProviderManagedAccount(t *testing.T) {\n\tconst templateBaseURL = \"https://api.xiaomimimo.com/anthropic\"\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_provider\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_provider\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"` + templateBaseURL + `\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"` + templateBaseURL + `\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\tbody := `{\"name\":\"mimo-main\",\"apiKeys\":[\"sk-existing\"],\"baseUrls\":[\"https://manual-pool.example.com\"]}`\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_provider\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PUT /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_provider\")\n\tif len(channels) == 0 {\n\t\tt.Fatalf(\"provider 托管账号渠道丢失\")\n\t}\n\tfor _, accountChannel := range channels {\n\t\tchannel := accountChannel.Upstream\n\t\tif channel.BaseURL != templateBaseURL {\n\t\t\tt.Fatalf(\"%s 渠道主地址被手工地址覆盖: %q\", accountChannel.Kind, channel.BaseURL)\n\t\t}\n\t\tfor _, baseURL := range channel.GetAllBaseURLs() {\n\t\t\tif strings.Contains(baseURL, \"manual-pool\") {\n\t\t\t\tt.Fatalf(\"%s 渠道地址池混入手工地址: %v\", accountChannel.Kind, channel.GetAllBaseURLs())\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestProviderRouteNameAndPrimaryResult(t *testing.T) {\n\tbase := \"mimo-test\"\n\ttests := []struct {\n\t\troute config.ProviderRoute\n\t\twant string\n\t}{\n\t\t{route: config.ProviderRoute{ChannelKind: \"messages\"}, want: \"mimo-test-claude\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"chat\"}, want: \"mimo-test-chat\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"responses\"}, want: \"mimo-test-codex\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"gemini\"}, want: \"mimo-test-gemini\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := providerRouteName(base, tt.route, true); got != tt.want {\n\t\t\tt.Fatalf(\"providerRouteName(%s)=%q, want %q\", tt.route.ChannelKind, got, tt.want)\n\t\t}\n\t}\n\tif got := providerRouteName(base, config.ProviderRoute{ChannelKind: \"messages\"}, false); got != base {\n\t\tt.Fatalf(\"single route name=%q, want %q\", got, base)\n\t}\n\n\tresults := []AutoAddChannelResult{\n\t\t{ChannelKind: \"messages\", ChannelUID: \"ch_messages\", Index: 1},\n\t\t{ChannelKind: \"chat\", ChannelUID: \"ch_chat\", Index: 2},\n\t\t{ChannelKind: \"responses\", ChannelUID: \"ch_responses\", Index: 3},\n\t}\n\tprimary := primaryAutoAddResult(results, \"chat\")\n\tif primary.ChannelUID != \"ch_chat\" || primary.Index != 2 {\n\t\tt.Fatalf(\"primary=%+v, want chat result\", primary)\n\t}\n}\n\nfunc TestManagedAccountUIDForProviderUsesExistingAccount(t *testing.T) {\n\tcfg := config.Config{ManagedAccounts: []config.ManagedAccountConfig{\n\t\t{AccountUID: \"acct-old\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-new\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-deepseek\", ProviderID: \"deepseek\"},\n\t}}\n\tif got := managedAccountUIDForProvider(cfg, \"mimo\"); got != \"acct-new\" {\n\t\tt.Fatalf(\"mimo existing account = %q, want acct-new\", got)\n\t}\n\tif got := managedAccountUIDForProvider(cfg, \"unknown\"); got != \"\" {\n\t\tt.Fatalf(\"unknown provider 不应命中账号: %q\", got)\n\t}\n}\n\nfunc TestListManagedAccountsDoesNotExposeVolcengineSecret(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-secret\",\"volcengineAccessKey\":{\"accessKeyId\":\"AKIDEXAMPLE\",\"secretAccessKey\":\"SECRET_MUST_NOT_LEAK\",\"plan\":\"agent_plan\",\"planTier\":\"Large\",\"planStatus\":\"Running\"}}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"SECRET_MUST_NOT_LEAK\") || strings.Contains(body, \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", body)\n\t}\n\tfor _, expected := range []string{`\"hasVolcengineAccessKey\":true`, `\"volcenginePlan\":\"agent_plan\"`, `\"volcenginePlanTier\":\"Large\"`} {\n\t\tif !strings.Contains(body, expected) {\n\t\t\tt.Fatalf(\"响应缺少 %s: %s\", expected, body)\n\t\t}\n\t}\n}\n\nfunc TestKimiConsoleTokenLifecycleDoesNotExposeToken(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_kimi\",\"providerId\":\"kimi\",\"name\":\"kimi\",\"credentials\":[{\"credentialUid\":\"cred_kimi\",\"apiKey\":\"sk-kimi-secret\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_kimi\",\"channelUid\":\"ch_kimi\",\"providerId\":\"kimi\",\"name\":\"kimi\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.kimi.com/coding\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_kimi\",\"baseUrl\":\"https://api.kimi.com/coding\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tkimiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Authorization\") != \"Bearer web-session-secret\" {\n\t\t\tt.Fatalf(\"Kimi 请求未使用保存的会话令牌\")\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase kimiUsagesPath:\n\t\t\t_, _ = w.Write([]byte(`{\"usages\":[{\"scope\":\"FEATURE_CODING\",\"detail\":{\"limit\":\"100\",\"remaining\":\"80\",\"resetTime\":\"2026-07-27T16:13:43Z\"},\"limits\":[{\"window\":{\"duration\":300,\"timeUnit\":\"TIME_UNIT_MINUTE\"},\"detail\":{\"limit\":\"100\",\"remaining\":\"90\",\"resetTime\":\"2026-07-21T02:13:43Z\"}}]}],\"totalQuota\":{\"limit\":\"100\",\"remaining\":\"80\"}}`))\n\t\tcase kimiSubscriptionStatsPath:\n\t\t\t_, _ = w.Write([]byte(`{\"ratelimitCode5h\":{\"ratio\":0.1,\"enabled\":true},\"ratelimitCode7d\":{\"ratio\":0.2,\"enabled\":true},\"subscriptionBalance\":{\"feature\":\"FEATURE_OMNI\",\"type\":\"SUBSCRIPTION\",\"unit\":\"UNIT_CREDIT\",\"amountUsedRatio\":0.2,\"kimiCodeUsedRatio\":0.2,\"expireTime\":\"2026-08-20T16:14:07Z\"}}`))\n\t\tdefault:\n\t\t\thttp.NotFound(w, r)\n\t\t}\n\t}))\n\tdefer kimiServer.Close()\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: cfgManager,\n\t\tKimiConsoleClient: &KimiConsoleClient{\n\t\t\tHTTPClient: kimiServer.Client(), BaseURL: kimiServer.URL,\n\t\t},\n\t})\n\tput := httptest.NewRequest(\n\t\thttp.MethodPut,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token\",\n\t\tbytes.NewBufferString(`{\"accessToken\":\"Bearer web-session-secret\"}`),\n\t)\n\tput.Header.Set(\"Content-Type\", \"application/json\")\n\tputRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(putRecorder, put)\n\tif putRecorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"绑定 Kimi 令牌失败: status=%d body=%s\", putRecorder.Code, putRecorder.Body.String())\n\t}\n\tif strings.Contains(putRecorder.Body.String(), \"web-session-secret\") || strings.Contains(putRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"绑定响应泄露 Kimi 令牌: %s\", putRecorder.Body.String())\n\t}\n\tcredential, ok := cfgManager.GetManagedAccountCredential(\"acct_kimi\", \"cred_kimi\")\n\tif !ok || credential.KimiConsole == nil || credential.KimiConsole.AccessToken != \"web-session-secret\" {\n\t\tt.Fatalf(\"Kimi 令牌未持久化: %+v\", credential)\n\t}\n\tpersisted, err := os.ReadFile(configPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !strings.Contains(string(persisted), `\"accessToken\": \"web-session-secret\"`) {\n\t\tt.Fatalf(\"配置文件未保存 Kimi 令牌\")\n\t}\n\n\tlistRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(listRecorder, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif listRecorder.Code != http.StatusOK || strings.Contains(listRecorder.Body.String(), \"web-session-secret\") || strings.Contains(listRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"账号列表响应异常或泄露令牌: status=%d body=%s\", listRecorder.Code, listRecorder.Body.String())\n\t}\n\tif !strings.Contains(listRecorder.Body.String(), `\"hasKimiConsoleToken\":true`) || !strings.Contains(listRecorder.Body.String(), `\"kimiCodeUsage\"`) {\n\t\tt.Fatalf(\"账号列表缺少 Kimi 用量快照: %s\", listRecorder.Body.String())\n\t}\n\n\trefreshRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(refreshRecorder, httptest.NewRequest(\n\t\thttp.MethodPost,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token/refresh\",\n\t\tnil,\n\t))\n\tif refreshRecorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"刷新 Kimi 余量失败: status=%d body=%s\", refreshRecorder.Code, refreshRecorder.Body.String())\n\t}\n\tif strings.Contains(refreshRecorder.Body.String(), \"web-session-secret\") || strings.Contains(refreshRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"刷新响应泄露 Kimi 令牌: %s\", refreshRecorder.Body.String())\n\t}\n\n\tdeleteRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(deleteRecorder, httptest.NewRequest(\n\t\thttp.MethodDelete,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token\",\n\t\tnil,\n\t))\n\tif deleteRecorder.Code != http.StatusNoContent {\n\t\tt.Fatalf(\"解绑 Kimi 令牌失败: status=%d body=%s\", deleteRecorder.Code, deleteRecorder.Body.String())\n\t}\n\tcredential, _ = cfgManager.GetManagedAccountCredential(\"acct_kimi\", \"cred_kimi\")\n\tif credential.KimiConsole != nil {\n\t\tt.Fatalf(\"Kimi 令牌解绑后仍存在: %+v\", credential.KimiConsole)\n\t}\n\tpersisted, err = os.ReadFile(configPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(persisted), \"web-session-secret\") {\n\t\tt.Fatalf(\"解绑后配置文件仍包含 Kimi 令牌\")\n\t}\n}\n\nfunc TestSetVolcengineAccessKeyDetectsPlanBeforePersisting(t *testing.T) {\n\tcontrolPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Query().Get(\"Action\") != \"GetPersonalPlan\" {\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tvar body struct{ Plan string }\n\t\t_ = json.NewDecoder(r.Body).Decode(&body)\n\t\tif body.Plan == \"CodingPlan\" {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t_, _ = w.Write([]byte(`{\"ResponseMetadata\":{\"Error\":{\"Code\":\"ResourceNotFound.Plan\",\"Message\":\"not found\"}}}`))\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\"Result\":{\"PlanType\":\"Large\",\"Status\":\"Running\"}}`))\n\t}))\n\tdefer controlPlane.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-inference\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trunner := NewAutoDiscoveryRunner(nil, nil)\n\trunner.client = controlPlane.Client()\n\trunner.volcengineControlPlaneEndpoint = controlPlane.URL\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, Runner: runner})\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_volc/credentials/cred_volc/volcengine-access-key\", bytes.NewBufferString(`{\"accessKeyId\":\"AKID\",\"secretAccessKey\":\"SECRET\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"plan\":\"agent_plan\"`) {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_volc\", \"cred_volc\")\n\tif !ok || credential.VolcengineAccessKey == nil || credential.VolcengineAccessKey.SecretAccessKey != \"SECRET\" || credential.VolcengineAccessKey.Plan != \"agent_plan\" {\n\t\tt.Fatalf(\"凭证未正确持久化: %+v\", credential)\n\t}\n}\n\nfunc TestSetMiMoConsoleCookieRequiresConfirmationBeforeAdoptingKey(t *testing.T) {\n\tconst oldKey = \"tp-old-1234567890123456789012345678901234567890\"\n\tconst cookieKey = \"tp-new-1234567890123456789012345678901234567890\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != \"api-platform_serviceToken=session; userId=42\" {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase \"/api/v1/userProfile\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"userId\":\"42\"}}`))\n\t\tcase \"/api/v1/tokenPlan/detail\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"planCode\":\"max\",\"planName\":\"Max\",\"currentPeriodEnd\":\"2026-07-29 23:59:59\"}}`))\n\t\tcase \"/api/v1/tokenPlan/usage\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"monthUsage\":{\"percent\":0.25,\"items\":[{\"used\":25,\"limit\":100}]},\"usage\":{\"percent\":0.5,\"items\":[{\"used\":50,\"limit\":100}]}}}`))\n\t\tcase \"/api/v1/tokenPlan/apiKey/raw\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":\"` + cookieKey + `\"}`))\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}\n\t}))\n\tdefer console.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"credentials\":[{\"credentialUid\":\"cred_mimo\",\"apiKey\":\"` + oldKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_mimo\",\"channelUid\":\"ch_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_mimo\",\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\tdeps := &AutoManagedDeps{CfgManager: manager, MiMoConsoleClient: &MiMoConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL}}\n\trouter := setupAutoManagedRouter(deps)\n\tbody := `{\"cookie\":\"api-platform_serviceToken=session; userId=42\"}`\n\trequest := func(payload string) *httptest.ResponseRecorder {\n\t\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_mimo/credentials/cred_mimo/mimo-console-cookie\", bytes.NewBufferString(payload))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tw := httptest.NewRecorder()\n\t\trouter.ServeHTTP(w, req)\n\t\treturn w\n\t}\n\tw := request(body)\n\tif w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), `\"code\":\"mimo_cookie_key_mismatch\"`) {\n\t\tt.Fatalf(\"未要求确认: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ := manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != oldKey || credential.MiMoConsole != nil {\n\t\tt.Fatalf(\"确认前不应修改配置: %+v\", credential)\n\t}\n\tw = request(`{\"cookie\":\"api-platform_serviceToken=session; userId=42\",\"adoptCookieKey\":true}`)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"keyAdopted\":true`) {\n\t\tt.Fatalf(\"确认采用失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ = manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != cookieKey || credential.MiMoConsole == nil {\n\t\tt.Fatalf(\"确认后未采用 Cookie Key: %+v\", credential)\n\t}\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif strings.Contains(list.Body.String(), \"api-platform_serviceToken\") || strings.Contains(list.Body.String(), cookieKey) {\n\t\tt.Fatalf(\"账号列表泄漏 Cookie 或原始 Key: %s\", list.Body.String())\n\t}\n}\n\nfunc TestCompshareConsoleCookieBindingDoesNotExposeSecrets(t *testing.T) {\n\tconst currentKey = \"sk-cp-test-current\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != testCompshareCookie {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\n \"RetCode\": 0,\n \"UserPlans\": [{\n \"Code\": \"cppkg-test\", \"PlanCode\": \"cp-plan-test\", \"PlanName\": \"Pro\", \"DisplayName\": \"Pro 增强版\",\n \"LimitPer5h\": 3000, \"LimitPerWeek\": 7500, \"LimitPerMonth\": 19000,\n \"UsagePer5h\": 0, \"UsagePerWeek\": 100, \"UsagePerMonth\": 6496,\n \"ConcurrencyLimit\": 10, \"Status\": 1, \"ExpireAt\": 1785037981,\n \"Keys\": [{\"APIKey\": \"` + currentKey + `\"}]\n }]\n}`))\n\t}))\n\tdefer console.Close()\n\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_compshare\",\"providerId\":\"compshare\",\"name\":\"compshare\",\"credentials\":[{\"credentialUid\":\"cred_compshare\",\"apiKey\":\"` + currentKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_compshare\",\"channelUid\":\"ch_compshare\",\"providerId\":\"compshare\",\"name\":\"compshare\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://api.compshare.cn/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_compshare\",\"baseUrl\":\"https://api.compshare.cn/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: manager,\n\t\tCompshareConsoleClient: &CompshareConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL},\n\t})\n\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_compshare/credentials/cred_compshare/compshare-console-cookie\", bytes.NewBufferString(`{\"cookie\":\"`+testCompshareCookie+`\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"displayName\":\"Pro 增强版\"`) {\n\t\tt.Fatalf(\"绑定失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_compshare\", \"cred_compshare\")\n\tif !ok || credential.CompshareConsole == nil || credential.CompshareConsole.Cookie != testCompshareCookie {\n\t\tt.Fatalf(\"控制台套餐未持久化: %+v\", credential)\n\t}\n\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tresponse := list.Body.String()\n\tfor _, secret := range []string{\"U_JWT_TOKEN\", \"test-session\", \"test_user@console.compshare.cn\", \"org-test\", currentKey, \"cppkg-test\"} {\n\t\tif strings.Contains(response, secret) {\n\t\t\tt.Fatalf(\"账号列表泄漏敏感字段 %q: %s\", secret, response)\n\t\t}\n\t}\n\tfor _, expected := range []string{`\"hasCompshareConsoleCookie\":true`, `\"planCode\":\"cp-plan-test\"`, `\"monthlyUsage\":{\"used\":6496,\"limit\":19000`} {\n\t\tif !strings.Contains(response, expected) {\n\t\t\tt.Fatalf(\"账号列表缺少套餐字段 %s: %s\", expected, response)\n\t\t}\n\t}\n}\n\nfunc TestProviderAutoAddReusesExistingAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(`{\"providerId\":\"mimo\",\"apiKeys\":[\"sk-existing\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"追加已有 provider key status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 4 {\n\t\tt.Fatalf(\"旧账号应自动补齐模板中的协议渠道: %+v\", channels)\n\t}\n\tkinds := make(map[string]bool, len(channels))\n\tfor _, channel := range channels {\n\t\tkinds[channel.Kind] = true\n\t}\n\tfor _, kind := range []string{\"messages\", \"chat\", \"responses\", \"gemini\"} {\n\t\tif !kinds[kind] {\n\t\t\tt.Fatalf(\"旧账号缺少 %s route: %+v\", kind, channels)\n\t\t}\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.AccountUID != \"acct_test\" {\n\t\tt.Fatalf(\"响应未返回已有账号: body=%s err=%v\", w.Body.String(), err)\n\t}\n\tif len(response.Channels) != 4 {\n\t\tt.Fatalf(\"响应未返回补齐后的全部渠道: %+v\", response.Channels)\n\t}\n}\n\nfunc TestMissingProviderAccountRoutes(t *testing.T) {\n\ttmpl, ok := config.GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"缺少 deepseek provider 模板\")\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{ServiceType: \"claude\"},\n\t}}\n\tmissing := missingProviderAccountRoutes(tmpl, existing)\n\tif len(missing) != 2 || missing[0].ChannelKind != \"chat\" || missing[1].ChannelKind != \"responses\" {\n\t\tt.Fatalf(\"missing routes = %+v\", missing)\n\t}\n\tconfigs, baseURLs, err := bindProviderRouteKeys(tmpl, missing[0], []string{\"sk-existing\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(configs) != 1 || configs[0].BaseURL != \"https://api.deepseek.com\" || len(baseURLs) != 1 {\n\t\tt.Fatalf(\"route 绑定结果不正确: configs=%+v baseURLs=%v\", configs, baseURLs)\n\t}\n}\n\nfunc TestBindProviderRouteKeysPreservesExistingEndpointAffinity(t *testing.T) {\n\ttests := []struct {\n\t\tproviderID string\n\t\tapiKey string\n\t\texistingBaseURL string\n\t\ttargetKind string\n\t\twantBaseURL string\n\t}{\n\t\t{\n\t\t\tproviderID: \"mimo\", apiKey: \"tp-existing\",\n\t\t\texistingBaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\t\ttargetKind: \"chat\", wantBaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\",\n\t\t},\n\t\t{\n\t\t\tproviderID: \"volcengine\", apiKey: \"ark-existing\",\n\t\t\texistingBaseURL: \"https://ark.cn-beijing.volces.com/api/coding\",\n\t\t\ttargetKind: \"responses\", wantBaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.providerID, func(t *testing.T) {\n\t\t\ttmpl, ok := config.GetProviderTemplate(tt.providerID)\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"缺少 provider 模板: %s\", tt.providerID)\n\t\t\t}\n\t\t\texisting := []config.AccountChannel{{\n\t\t\t\tKind: \"messages\",\n\t\t\t\tUpstream: config.UpstreamConfig{\n\t\t\t\t\tProviderID: tt.providerID, ServiceType: \"claude\", APIKeys: []string{tt.apiKey},\n\t\t\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: tt.apiKey, BaseURL: tt.existingBaseURL}},\n\t\t\t\t},\n\t\t\t}}\n\t\t\tvar target config.ProviderRoute\n\t\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\t\tif route.ChannelKind == tt.targetKind {\n\t\t\t\t\ttarget = route\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tconfigs, _, err := bindProviderRouteKeysWithAffinities(\n\t\t\t\ttmpl, target, []string{tt.apiKey}, providerKeyCandidateAffinities(tmpl, existing),\n\t\t\t)\n\t\t\tif err != nil || len(configs) != 1 || configs[0].BaseURL != tt.wantBaseURL {\n\t\t\t\tt.Fatalf(\"亲和绑定错误: configs=%+v err=%v want=%s\", configs, err, tt.wantBaseURL)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPlanProviderAccountRouteAdditionsRejectsNewKeyBeforeMutation(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t}))\n\tdefer server.Close()\n\n\ttmpl := &config.ProviderTemplate{\n\t\tProviderID: \"test-provider\",\n\t\tRoutes: []config.ProviderRoute{\n\t\t\t{ChannelKind: \"messages\", ServiceType: \"claude\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/anthropic\"}}},\n\t\t\t{ChannelKind: \"chat\", ServiceType: \"openai\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/v1\"}}},\n\t\t},\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct_test\", ChannelUID: \"ch_messages\", Name: \"test-provider\",\n\t\t\tProviderID: \"test-provider\", ServiceType: \"claude\", AutoManaged: true, APIKeys: []string{\"sk-existing\"},\n\t\t},\n\t}}\n\tadditions, status, err := planProviderAccountRouteAdditions(\n\t\tt.Context(), config.Config{}, tmpl, \"acct_test\", []string{\"sk-existing\", \"sk-invalid\"}, existing,\n\t)\n\tif err == nil || status != http.StatusBadRequest || len(additions) != 0 {\n\t\tt.Fatalf(\"无效新 Key 应在生成新增 route 前失败: status=%d additions=%+v err=%v\", status, additions, err)\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}, "71b842e3e8300d0b9329446af03a39c498580b55": {"t": 1786067580.7536426, "n": 68677, "h": "9a46edf960bd44dc", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/BenedictKing/ccx/internal/errutil\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestListAccountsMasksCredentials(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-secret-value\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil)\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"sk-secret-value\") {\n\t\tt.Fatalf(\"账号列表泄露明文 Key: %s\", body)\n\t}\n\tif !strings.Contains(body, \"cred_test\") || !strings.Contains(body, \"keyMask\") {\n\t\tt.Fatalf(\"账号列表缺少凭证掩码信息: %s\", body)\n\t}\n}\n\nfunc TestListAccountsIncludesActiveEndpointModelAvailability(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"volcengine\",\"name\":\"volcengine\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"ark-secret-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"ark-secret-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"volcengine\",\"name\":\"volcengine-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://example.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tstore, err := NewProfileStoreWithDB(newTestDB(t))\n\tif err != nil {\n\t\tt.Fatalf(\"NewProfileStoreWithDB 失败: %v\", err)\n\t}\n\tupdatedAt := time.Date(2026, 7, 17, 10, 54, 33, 0, time.UTC)\n\tdiscoveredAt := updatedAt\n\tdiscoveredAtLater := updatedAt.Add(time.Minute)\n\tfor _, profile := range []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_a\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_a\", KeyMask: \"ark-a***001\", AvailableModels: []string{\"model-b\", \"model-a\"}, UpdatedAt: updatedAt,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI, ModelsDiscoveredAt: &discoveredAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_b\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_b\", KeyMask: \"ark-b***002\", AvailableModels: []string{\"model-c\", \"model-b\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI, ModelsDiscoveredAt: &discoveredAtLater,\n\t\t},\n\t} {\n\t\tif err := store.Upsert(profile); err != nil {\n\t\t\tt.Fatalf(\"ProfileStore.Upsert 失败: %v\", err)\n\t\t}\n\t}\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager, Runner: runner})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response struct {\n\t\tAccounts []managedAccountView `json:\"accounts\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatalf(\"解析响应失败: %v\", err)\n\t}\n\tif len(response.Accounts) != 1 || len(response.Accounts[0].Channels) != 1 {\n\t\tt.Fatalf(\"账号渠道数量错误: %+v\", response.Accounts)\n\t}\n\tchannel := response.Accounts[0].Channels[0]\n\tif got, want := strings.Join(channel.DiscoveredModels, \",\"), \"model-a,model-b,model-c\"; got != want {\n\t\tt.Fatalf(\"DiscoveredModels=%q, want %q\", got, want)\n\t}\n\tif !channel.ModelInventoryKnown {\n\t\tt.Fatal(\"已发现模型时 ModelInventoryKnown 应为 true\")\n\t}\n\tif len(channel.ModelBindings) != 2 {\n\t\tt.Fatalf(\"ModelBindings=%+v, want 2\", channel.ModelBindings)\n\t}\n\tif got := strings.Join(channel.ModelBindings[0].Models, \",\"); got != \"model-a,model-b\" {\n\t\tt.Fatalf(\"第一个 Key 模型=%q\", got)\n\t}\n\tif channel.ModelsUpdatedAt == nil || !channel.ModelsUpdatedAt.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"ModelsUpdatedAt=%v\", channel.ModelsUpdatedAt)\n\t}\n\tif strings.Contains(w.Body.String(), \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", w.Body.String())\n\t}\n}\n\nfunc TestListAccountsIncludesCustomAutoManagedProtocolAvailability(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [],\n \"upstream\": [], \"chatUpstream\": [],\n \"responsesUpstream\": [{\"accountUid\":\"acct_custom\",\"channelUid\":\"ch_custom\",\"name\":\"custom-endpoint\",\"serviceType\":\"responses\",\"baseUrl\":\"https://example.com\",\"apiKeys\":[\"sk-custom-secret\"],\"apiKeyConfigs\":[{\"key\":\"sk-custom-secret\",\"credentialUid\":\"cred_custom\",\"baseUrl\":\"https://example.com\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tstore, err := NewProfileStoreWithDB(newTestDB(t))\n\tif err != nil {\n\t\tt.Fatalf(\"NewProfileStoreWithDB 失败: %v\", err)\n\t}\n\tdiscoveredAt := time.Date(2026, 7, 25, 5, 11, 29, 0, time.UTC)\n\tprofile := &KeyEndpointProfile{\n\t\tEndpointUID: \"ep_custom\", AccountUID: \"acct_custom\", ChannelUID: \"ch_custom\",\n\t\tCredentialUID: \"cred_custom\", KeyMask: \"sk-c***ret\", ServiceType: \"responses\",\n\t\tAvailableModels: []string{\"gpt-5.6-sol\"}, ModelsDiscoveredAt: &discoveredAt,\n\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI,\n\t\tProtocolModels: map[string][]string{\n\t\t\t\"messages\": {\"gpt-5.6-sol\"},\n\t\t\t\"chat\": {\"gpt-5.6-sol\"},\n\t\t\t\"responses\": {\"gpt-5.6-sol\"},\n\t\t},\n\t\tProtocolDiscoveredAt: map[string]time.Time{\n\t\t\t\"messages\": discoveredAt, \"chat\": discoveredAt, \"responses\": discoveredAt,\n\t\t},\n\t\tProtocolDiscoverySource: map[string]string{\n\t\t\t\"messages\": \"protocol_probe\", \"chat\": \"protocol_probe\", \"responses\": \"models_api\",\n\t\t},\n\t\tUpdatedAt: discoveredAt,\n\t}\n\tif err := store.Upsert(profile); err != nil {\n\t\tt.Fatalf(\"ProfileStore.Upsert 失败: %v\", err)\n\t}\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: cfgManager,\n\t\tRunner: NewAutoDiscoveryRunner(store, nil),\n\t})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tif strings.Contains(w.Body.String(), \"sk-custom-secret\") {\n\t\tt.Fatalf(\"账号列表泄露自定义渠道明文 Key: %s\", w.Body.String())\n\t}\n\n\tvar response struct {\n\t\tAccounts []managedAccountView `json:\"accounts\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatalf(\"解析响应失败: %v\", err)\n\t}\n\tif len(response.Accounts) != 1 {\n\t\tt.Fatalf(\"Accounts=%+v, want 1\", response.Accounts)\n\t}\n\taccount := response.Accounts[0]\n\tif account.AccountUID != \"acct_custom\" || len(account.Credentials) != 1 || len(account.Channels) != 1 {\n\t\tt.Fatalf(\"自定义自动托管账号聚合错误: %+v\", account)\n\t}\n\tprotocols := make([]string, 0, len(account.Channels[0].ProtocolAvailability))\n\tfor _, availability := range account.Channels[0].ProtocolAvailability {\n\t\tprotocols = append(protocols, availability.Protocol)\n\t}\n\tif got, want := strings.Join(protocols, \",\"), \"messages,chat,responses\"; got != want {\n\t\tt.Fatalf(\"ProtocolAvailability=%q, want %q\", got, want)\n\t}\n}\n\nfunc TestManagedChannelModelAvailabilityIncludesEmptyAutoDiscoveryResult(t *testing.T) {\n\tupdatedAt := time.Date(2026, 7, 17, 11, 2, 0, 0, time.UTC)\n\tmodels, bindings, latest, known := managedChannelModelAvailability([]*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_empty\", CredentialUID: \"cred_empty\", KeyMask: \"ark-e***001\",\n\t\t\tSource: \"auto_discovery\", AvailableModels: []string{}, UpdatedAt: updatedAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_models\", CredentialUID: \"cred_models\", KeyMask: \"ark-m***002\",\n\t\t\tAvailableModels: []string{\"model-a\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_unknown\", CredentialUID: \"cred_unknown\", KeyMask: \"ark-u***003\",\n\t\t\tSource: \"l1_passive\", UpdatedAt: updatedAt.Add(2 * time.Minute),\n\t\t},\n\t})\n\n\tif !known {\n\t\tt.Fatal(\"自动发现的空模型清单仍应标记为已知\")\n\t}\n\tif got, want := strings.Join(models, \",\"), \"model-a\"; got != want {\n\t\tt.Fatalf(\"models=%q, want %q\", got, want)\n\t}\n\tif len(bindings) != 2 {\n\t\tt.Fatalf(\"bindings=%+v, want only two known inventories\", bindings)\n\t}\n\tif got := bindings[0]; got.KeyMask != \"ark-e***001\" || len(got.Models) != 0 {\n\t\tt.Fatalf(\"empty discovery binding=%+v, want empty ark-e binding\", got)\n\t}\n\tif latest == nil || !latest.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"latest=%v, want discovered inventory update time\", latest)\n\t}\n}\n\nfunc TestManagedChannelModelAvailabilityIncludesDiscoveryMetadata(t *testing.T) {\n\tfirst := time.Date(2026, 7, 22, 0, 42, 12, 0, time.UTC)\n\tsecond := first.Add(time.Minute)\n\tinventory := managedChannelModelAvailabilityDetails([]*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep-a\",\n\t\t\tCredentialUID: \"cred-a\",\n\t\t\tKeyMask: \"ark-a***001\",\n\t\t\tAvailableModels: []string{\"glm-5.2\"},\n\t\t\tUpdatedAt: first,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceControlPlane,\n\t\t\tModelDiscoveryMessage: \"火山管控面 Coding Plan 模型清单\",\n\t\t\tModelsDiscoveredAt: &first,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep-b\",\n\t\t\tCredentialUID: \"cred-b\",\n\t\t\tKeyMask: \"ark-b***002\",\n\t\t\tAvailableModels: []string{\"deepseek-v4-pro\"},\n\t\t\tUpdatedAt: second,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceBuiltinFallback,\n\t\t\tModelDiscoveryMessage: \"管控面失败,已回退内置模型清单\",\n\t\t\tModelsDiscoveredAt: &second,\n\t\t},\n\t})\n\n\tif !inventory.known || inventory.source != \"mixed\" {\n\t\tt.Fatalf(\"inventory source=%q known=%v\", inventory.source, inventory.known)\n\t}\n\tif !inventory.latestDiscoveredAt.Equal(second) || inventory.message != \"管控面失败,已回退内置模型清单\" {\n\t\tt.Fatalf(\"inventory discovery metadata=%+v\", inventory)\n\t}\n\tif len(inventory.bindings) != 2 || inventory.bindings[1].ModelsDiscoveredAt == nil {\n\t\tt.Fatalf(\"binding discovery metadata=%+v\", inventory.bindings)\n\t}\n}\n\nfunc TestManagedProtocolAvailabilityViewsAggregatesByProtocolAndCredential(t *testing.T) {\n\tdiscoveredAt := time.Date(2026, 7, 25, 10, 0, 0, 0, time.UTC)\n\tprofiles := []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep-a\", CredentialUID: \"cred-a\", KeyMask: \"sk-a***001\", ServiceType: \"responses\",\n\t\t\tUpdatedAt: discoveredAt, ProtocolModels: map[string][]string{\n\t\t\t\t\"responses\": {\"shared\", \"responses-only\"},\n\t\t\t\t\"chat\": {\"shared\"},\n\t\t\t},\n\t\t\tProtocolDiscoveredAt: map[string]time.Time{\"responses\": discoveredAt, \"chat\": discoveredAt},\n\t\t\tProtocolDiscoverySource: map[string]string{\"responses\": \"models_api\", \"chat\": \"protocol_probe\"},\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep-b\", CredentialUID: \"cred-b\", KeyMask: \"sk-b***002\", ServiceType: \"responses\",\n\t\t\tUpdatedAt: discoveredAt, ProtocolModels: map[string][]string{\n\t\t\t\t\"responses\": {\"shared\"},\n\t\t\t\t\"messages\": {\"shared\", \"messages-only\"},\n\t\t\t},\n\t\t\tProtocolDiscoveredAt: map[string]time.Time{\"responses\": discoveredAt, \"messages\": discoveredAt},\n\t\t\tProtocolDiscoverySource: map[string]string{\"responses\": \"models_api\", \"messages\": \"protocol_probe\"},\n\t\t},\n\t}\n\n\tviews := managedProtocolAvailabilityViews(profiles)\n\tif len(views) != 3 {\n\t\tt.Fatalf(\"views=%+v\", views)\n\t}\n\tif views[0].Protocol != \"messages\" || strings.Join(views[0].DiscoveredModels, \",\") != \"messages-only,shared\" {\n\t\tt.Fatalf(\"messages view=%+v\", views[0])\n\t}\n\tif views[2].Protocol != \"responses\" || len(views[2].ModelBindings) != 2 {\n\t\tt.Fatalf(\"responses view=%+v\", views[2])\n\t}\n}\n\nfunc TestPatchAccountCredentialsRemovesByUID(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"sk-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"sk-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"baseUrls\":[\"https://api.xiaomimimo.com/anthropic\"],\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPatch, \"/api/accounts/acct_test/credentials\", bytes.NewBufferString(`{\"removeCredentialUids\":[\"cred_b\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PATCH credentials status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 1 || len(channels[0].Upstream.APIKeys) != 1 || channels[0].Upstream.APIKeys[0] != \"sk-a\" {\n\t\tt.Fatalf(\"按 credentialUid 删除失败: %+v\", channels)\n\t}\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestInferAutoAddProviderID(t *testing.T) {\n\tzhipuKey := \"0123456789abcdef0123456789abcdef.ABCDEFGHIJKLMNO1\"\n\ttests := []struct {\n\t\tname string\n\t\tbaseURLs []string\n\t\tapiKeys []string\n\t\twant string\n\t}{\n\t\t{name: \"官方 Claude URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"官方 OpenAI URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4/\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"两个官方协议 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\", \"https://open.bigmodel.cn/api/paas/v4\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"仅智谱 Key\", apiKeys: []string{zhipuKey}, want: \"glm\"},\n\t\t{name: \"第三方 URL 不按 Key 提升\", baseURLs: []string{\"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"混合官方与第三方 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4\", \"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"共享 sk Key 无法推断\", apiKeys: []string{\"sk-abcdefghijklmnopqrstuvwxyz123456\"}},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := inferAutoAddProviderID(tt.baseURLs, tt.apiKeys); got != tt.want {\n\t\t\t\tt.Fatalf(\"inferAutoAddProviderID() = %q, want %q\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestApplyProviderUpstreamDefaults(t *testing.T) {\n\t// shouldPassbackReasoningContentByDefault 按 ProviderID+ServiceType 静态推导(GLM 已知\n\t// 协议事实),ApplyProviderUpstreamDefaults 不再写字段,测试必须设置 ProviderID 才能命中。\n\tglmChat := config.UpstreamConfig{ProviderID: \"glm\", ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmChat)\n\tif glmChat.ReasoningParamStyle != \"reasoning_effort\" || !glmChat.IsPassbackReasoningContentEnabled() {\n\t\tt.Fatalf(\"GLM OpenAI 默认兼容参数未补齐: %+v\", glmChat)\n\t}\n\n\tglmClaude := config.UpstreamConfig{ProviderID: \"glm\", ServiceType: \"claude\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmClaude)\n\tif glmClaude.ReasoningParamStyle != \"\" || glmClaude.IsPassbackReasoningContentEnabled() {\n\t\tt.Fatalf(\"GLM Claude 原生 route 不应注入 OpenAI 参数: %+v\", glmClaude)\n\t}\n\n\tcustom := config.UpstreamConfig{ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"\", &custom)\n\tif custom.ReasoningParamStyle != \"\" || custom.IsPassbackReasoningContentEnabled() {\n\t\tt.Fatalf(\"自定义渠道不应应用 GLM 默认值: %+v\", custom)\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCustomAutoAddResponseIncludesActualRoute(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\tdiscoverer := NewRateLimitDiscoverer(RateLimitDiscovererConfig{PassiveAimdEnabled: true, QuietLogs: true})\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, RateLimitDiscoverer: discoverer})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"fastaitoken-com-test\",\"baseUrls\":[\"https://example.com\"],\"apiKeys\":[\"sk-test\"],\"rateLimitHint\":{\"initialRpm\":30,\"effectiveRpm\":15,\"rateLimited\":true,\"rateLimitedCount\":1}}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 {\n\t\tt.Fatalf(\"channels=%+v, want one actual route\", response.Channels)\n\t}\n\troute := response.Channels[0]\n\tif route.ChannelKind != \"responses\" || route.ServiceType != \"responses\" || route.ChannelUID != response.ChannelUID {\n\t\tt.Fatalf(\"route=%+v response=%+v\", route, response)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.ResponsesUpstream) != 1 || len(cfg.Upstream) != 0 {\n\t\tt.Fatalf(\"custom route persisted in wrong channel: responses=%d messages=%d\", len(cfg.ResponsesUpstream), len(cfg.Upstream))\n\t}\n\tcreated := cfg.ResponsesUpstream[0]\n\tbaseURL := created.GetEffectiveBaseURL()\n\tendpointUID := GenerateEndpointUID(created.ChannelUID, baseURL, KeyHashFromAPIKey(\"sk-test\"))\n\tif suggestion := discoverer.SuggestedLimit(endpointUID); suggestion.RPM != 15 || suggestion.Confidence != 0.7 {\n\t\tt.Fatalf(\"rate limit suggestion=%+v, want rpm=15 confidence=0.7\", suggestion)\n\t}\n\n\tstore := newTestProfileStore(t)\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\tseedAutoAddRateLimitHint(\n\t\t&AutoManagedDeps{CfgManager: manager, Runner: runner, RateLimitDiscoverer: discoverer},\n\t\tmanager.GetAccountChannels(response.AccountUID),\n\t\t&AutoAddRateLimitHint{InitialRPM: 30, EffectiveRPM: 15, RateLimited: true, RateLimitedCount: 1},\n\t\t\"sk-test\",\n\t\t\"https://example.com\",\n\t)\n\tprofile := store.Get(endpointUID)\n\tif profile == nil || profile.DiscoveredRPM != 15 || profile.RateLimitConfidence != 0.7 || profile.RateLimitSource != string(RateLimitSourcePassiveAIMD) {\n\t\tt.Fatalf(\"rate limit profile=%+v\", profile)\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyToEquivalentExistingBaseURL(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [],\n \"chatUpstream\": [{\"channelUid\":\"ch_existing\",\"name\":\"localhost-old-name\",\"serviceType\":\"openai\",\"baseUrl\":\"http://localhost:8990/v1\",\"apiKeys\":[\"sk-old\"],\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"localhost-8990\",\"baseUrls\":[\"http://localhost:8990/\"],\"apiKeys\":[\"sk-new\"]}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 || response.Channels[0].Name != \"localhost-old-name\" {\n\t\tt.Fatalf(\"应返回已有渠道: %+v\", response.Channels)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 0 || len(cfg.ChatUpstream) != 1 {\n\t\tt.Fatalf(\"重复请求不应创建新渠道: messages=%d chat=%d\", len(cfg.Upstream), len(cfg.ChatUpstream))\n\t}\n\tif got := strings.Join(cfg.ChatUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"已有渠道密钥=%q, want sk-old,sk-new\", got)\n\t}\n}\n\nfunc TestFindExistingAutoAddChannelsPreservesHashSemantics(t *testing.T) {\n\tcfg := config.Config{\n\t\tChatUpstream: []config.UpstreamConfig{{\n\t\t\tChannelUID: \"ch-hash\", ServiceType: \"openai\", BaseURL: \"https://example.com#\",\n\t\t}},\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com\"}); len(matches) != 0 {\n\t\tt.Fatalf(\"普通 Base URL 不应匹配禁止版本前缀的地址: %+v\", matches)\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com/#\"}); len(matches) != 1 {\n\t\tt.Fatalf(\"等效 # 地址匹配数量=%d, want 1\", len(matches))\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyAndMissingRouteToManagedAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [{\"accountUid\":\"acct_existing\",\"channelUid\":\"ch_existing\",\"name\":\"localhost-8990\",\"serviceType\":\"claude\",\"baseUrl\":\"http://localhost:8990\",\"apiKeys\":[\"sk-old\"],\"autoManaged\":true,\"status\":\"active\"}],\n \"chatUpstream\": [], \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"localhost-8990\",\n \"baseUrls\":[\"http://localhost:8990/v1\"],\n \"apiKeys\":[\"sk-new\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"claude-test\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"gpt-test\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d\", len(cfg.Upstream), len(cfg.ResponsesUpstream))\n\t}\n\tif cfg.Upstream[0].AccountUID != \"acct_existing\" || cfg.ResponsesUpstream[0].AccountUID != \"acct_existing\" {\n\t\tt.Fatalf(\"应复用已有账号: messages=%q responses=%q\", cfg.Upstream[0].AccountUID, cfg.ResponsesUpstream[0].AccountUID)\n\t}\n\tif got := strings.Join(cfg.Upstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"messages 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif got := strings.Join(cfg.ResponsesUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"responses 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif cfg.Upstream[0].Name != \"localhost-8990-claude\" || cfg.ResponsesUpstream[0].Name != \"localhost-8990-codex\" {\n\t\tt.Fatalf(\"多协议自动命名错误: messages=%q responses=%q\", cfg.Upstream[0].Name, cfg.ResponsesUpstream[0].Name)\n\t}\n}\n\nfunc TestCustomAutoAddCreatesAllDetectedRoutesWithProtocolModels(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"fastaitoken-com-test\",\n \"baseUrls\":[\"https://example.com/keys\"],\n \"apiKeys\":[\"sk-test\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"shared\",\"messages-only\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"shared\",\"responses-only\"]},\n {\"channelKind\":\"chat\",\"supportedModels\":[\"shared\",\"chat-only\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 3 {\n\t\tt.Fatalf(\"channels=%+v, want three detected routes\", response.Channels)\n\t}\n\tfor _, route := range response.Channels {\n\t\tif route.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route account=%q, want %q\", route.AccountUID, response.AccountUID)\n\t\t}\n\t}\n\tif response.Channels[1].ChannelKind != \"responses\" || response.ChannelUID != response.Channels[1].ChannelUID {\n\t\tt.Fatalf(\"请求协议应作为主路由: response=%+v\", response)\n\t}\n\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 || len(cfg.ChatUpstream) != 1 || len(cfg.GeminiUpstream) != 0 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d chat=%d gemini=%d\",\n\t\t\tlen(cfg.Upstream), len(cfg.ResponsesUpstream), len(cfg.ChatUpstream), len(cfg.GeminiUpstream))\n\t}\n\tchecks := []struct {\n\t\tchannel config.UpstreamConfig\n\t\tname string\n\t\tmodels []string\n\t}{\n\t\t{cfg.Upstream[0], \"fastaitoken-com-test-claude\", []string{\"shared\", \"messages-only\"}},\n\t\t{cfg.ResponsesUpstream[0], \"fastaitoken-com-test-codex\", []string{\"shared\", \"responses-only\"}},\n\t\t{cfg.ChatUpstream[0], \"fastaitoken-com-test-chat\", []string{\"shared\", \"chat-only\"}},\n\t}\n\tfor _, check := range checks {\n\t\tif check.channel.Name != check.name || check.channel.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route identity=%+v, want name=%q account=%q\", check.channel, check.name, response.AccountUID)\n\t\t}\n\t\tif strings.Join(check.channel.SupportedModels, \",\") != strings.Join(check.models, \",\") {\n\t\t\tt.Fatalf(\"route %s models=%v, want %v\", check.name, check.channel.SupportedModels, check.models)\n\t\t}\n\t}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesRenamesAndSyncsCredentials(t *testing.T) {\n\tchannels := []config.AccountChannel{\n\t\t{Kind: \"messages\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-messages\", Name: \"old-claude\",\n\t\t\tServiceType: \"claude\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t\t{Kind: \"responses\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-responses\", Name: \"old-codex\",\n\t\t\tServiceType: \"responses\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t}\n\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\tName: \"renamed\", APIKeys: []string{\"sk-old\", \"sk-new\"},\n\t}, channels, len(channels))\n\tif err != nil || status != http.StatusOK {\n\t\tt.Fatalf(\"status=%d err=%v\", status, err)\n\t}\n\tif len(updates) != 2 || updates[0].Name != \"renamed-claude\" || updates[1].Name != \"renamed-codex\" {\n\t\tt.Fatalf(\"route names=%+v\", updates)\n\t}\n\tfor _, update := range updates {\n\t\tif len(update.APIKeyConfig) != 2 || update.APIKeyConfig[1].BaseURL != \"https://example.com\" {\n\t\t\tt.Fatalf(\"route credentials=%+v\", update.APIKeyConfig)\n\t\t}\n\t\tif update.APIKeyConfig[1].CredentialUID == \"\" {\n\t\t\tt.Fatalf(\"new credential missing stable uid: %+v\", update.APIKeyConfig[1])\n\t\t}\n\t}\n}\n\n// newCustomManagedChannel 构造一条自定义自动托管渠道,用于地址池编辑链路的表驱动断言。\nfunc newCustomManagedChannel(kind, serviceType string, baseURLs []string, keyConfigs []config.APIKeyConfig) config.AccountChannel {\n\tapiKeys := make([]string, 0, len(keyConfigs))\n\tfor _, keyConfig := range keyConfigs {\n\t\tapiKeys = append(apiKeys, keyConfig.Key)\n\t}\n\tprimary := \"\"\n\tif len(baseURLs) > 0 {\n\t\tprimary = baseURLs[0]\n\t}\n\treturn config.AccountChannel{Kind: kind, Upstream: config.UpstreamConfig{\n\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-\" + kind, Name: \"custom\" + accountRouteSuffix(kind),\n\t\tServiceType: serviceType, AutoManaged: true, BaseURL: primary,\n\t\tBaseURLs: append([]string(nil), baseURLs...), APIKeys: apiKeys,\n\t\tAPIKeyConfigs: append([]config.APIKeyConfig(nil), keyConfigs...),\n\t}}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesBaseURLPool(t *testing.T) {\n\tconst (\n\t\turlA = \"https://a.example.com\"\n\t\turlB = \"https://b.example.com\"\n\t\turlC = \"https://c.example.com\"\n\t)\n\ttests := []struct {\n\t\tname string\n\t\t// existingURLs / existingKeys 描述渠道现状,requestURLs 为请求里的 baseUrls(nil 表示未提供)\n\t\texistingURLs []string\n\t\texistingKeys []config.APIKeyConfig\n\t\tapiKeys []string\n\t\trequestURLs []string\n\t\twantStatus int\n\t\twantErr bool\n\t\twantBaseURLs []string\n\t\t// wantBindings 与 apiKeys 顺序一致,空串表示该 Key 未绑定端点\n\t\twantBindings []string\n\t}{\n\t\t{\n\t\t\tname: \"单地址扩为两地址\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA, urlB},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlB},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"两地址删除其中一个且保留有效绑定\",\n\t\t\texistingURLs: []string{urlA, urlB},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"地址顺序变化改变首地址\",\n\t\t\texistingURLs: []string{urlA, urlB},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlB, urlA},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlB, urlA},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"尾部斜杠与默认版本前缀等价地址去重\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA, urlA + \"/\", urlA + \"/v1\", \" \" + urlB + \"/ \", urlB},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlB},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"全部非法地址被拒绝\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{\"\", \" \", \"/\"},\n\t\t\twantStatus: http.StatusBadRequest,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"未提供地址时沿用渠道现有地址\",\n\t\t\texistingURLs: []string{urlA, urlB},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlB}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: nil,\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlB},\n\t\t\twantBindings: []string{urlB},\n\t\t},\n\t\t{\n\t\t\tname: \"绑定指向已删除地址时迁移到新首地址\",\n\t\t\texistingURLs: []string{urlA, urlB},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlB}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA, urlC},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlC},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"原本未绑定的凭证保持未绑定\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\"}, {Key: \"sk-2\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\", \"sk-2\", \"sk-new\"},\n\t\t\trequestURLs: []string{urlB, urlA},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlB, urlA},\n\t\t\t// sk-1 未绑定保持为空;sk-2 绑定仍在池内保持不变;新增 sk-new 绑定首地址\n\t\t\twantBindings: []string{\"\", urlA, urlB},\n\t\t},\n\t\t{\n\t\t\tname: \"非规范绑定被折叠为 canonical 形式\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA + \"/v1/\"}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA, urlB},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlB},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tchannels := []config.AccountChannel{\n\t\t\t\tnewCustomManagedChannel(\"messages\", \"claude\", tt.existingURLs, tt.existingKeys),\n\t\t\t}\n\t\t\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\t\t\tName: \"custom\", APIKeys: tt.apiKeys, BaseURLs: tt.requestURLs,\n\t\t\t}, channels, len(channels))\n\t\t\tif status != tt.wantStatus || (err != nil) != tt.wantErr {\n\t\t\t\tt.Fatalf(\"status=%d err=%v, want status=%d err=%v\", status, err, tt.wantStatus, tt.wantErr)\n\t\t\t}\n\t\t\tif tt.wantErr {\n\t\t\t\tif len(updates) != 0 {\n\t\t\t\t\tt.Fatalf(\"非法输入不应产生渠道更新: %+v\", updates)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(updates) != 1 {\n\t\t\t\tt.Fatalf(\"updates=%+v, want 1\", updates)\n\t\t\t}\n\t\t\tupdate := updates[0]\n\t\t\tif strings.Join(update.BaseURLs, \",\") != strings.Join(tt.wantBaseURLs, \",\") {\n\t\t\t\tt.Fatalf(\"BaseURLs=%v, want %v\", update.BaseURLs, tt.wantBaseURLs)\n\t\t\t}\n\t\t\tif len(update.APIKeyConfig) != len(tt.wantBindings) {\n\t\t\t\tt.Fatalf(\"APIKeyConfig=%+v, want %d 条\", update.APIKeyConfig, len(tt.wantBindings))\n\t\t\t}\n\t\t\tfor i, keyConfig := range update.APIKeyConfig {\n\t\t\t\tif keyConfig.Key != tt.apiKeys[i] {\n\t\t\t\t\tt.Fatalf(\"APIKeyConfig[%d].Key=%q, want %q\", i, keyConfig.Key, tt.apiKeys[i])\n\t\t\t\t}\n\t\t\t\tif keyConfig.BaseURL != tt.wantBindings[i] {\n\t\t\t\t\tt.Fatalf(\"APIKeyConfig[%d](%s).BaseURL=%q, want %q\", i, keyConfig.Key, keyConfig.BaseURL, tt.wantBindings[i])\n\t\t\t\t}\n\t\t\t\tif keyConfig.CredentialUID == \"\" {\n\t\t\t\t\tt.Fatalf(\"APIKeyConfig[%d] 缺少稳定 credentialUid: %+v\", i, keyConfig)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesSyncsBaseURLPoolAcrossProtocols(t *testing.T) {\n\tconst (\n\t\turlA = \"https://a.example.com\"\n\t\turlB = \"https://b.example.com\"\n\t)\n\texistingKeys := []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}}\n\tchannels := []config.AccountChannel{\n\t\tnewCustomManagedChannel(\"messages\", \"claude\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"chat\", \"openai\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"responses\", \"responses\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"gemini\", \"gemini\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"images\", \"openai\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"vectors\", \"openai\", []string{urlA}, existingKeys),\n\t}\n\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\tName: \"custom\", APIKeys: []string{\"sk-1\"}, BaseURLs: []string{urlB + \"/\", urlA},\n\t}, channels, len(channels))\n\tif err != nil || status != http.StatusOK {\n\t\tt.Fatalf(\"status=%d err=%v\", status, err)\n\t}\n\tif len(updates) != len(channels) {\n\t\tt.Fatalf(\"updates=%d, want %d\", len(updates), len(channels))\n\t}\n\twantPool := urlB + \",\" + urlA\n\tfor i, update := range updates {\n\t\tif got := strings.Join(update.BaseURLs, \",\"); got != wantPool {\n\t\t\tt.Fatalf(\"%s 渠道地址池=%q, want %q\", channels[i].Kind, got, wantPool)\n\t\t}\n\t\tif len(update.APIKeyConfig) != 1 || update.APIKeyConfig[0].BaseURL != urlA {\n\t\t\tt.Fatalf(\"%s 渠道凭证绑定=%+v, want %s\", channels[i].Kind, update.APIKeyConfig, urlA)\n\t\t}\n\t}\n}\n\nfunc TestUpdateAccountIgnoresBaseURLsForProviderManagedAccount(t *testing.T) {\n\tconst templateBaseURL = \"https://api.xiaomimimo.com/anthropic\"\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_provider\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_provider\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"` + templateBaseURL + `\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"` + templateBaseURL + `\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\tbody := `{\"name\":\"mimo-main\",\"apiKeys\":[\"sk-existing\"],\"baseUrls\":[\"https://manual-pool.example.com\"]}`\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_provider\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PUT /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_provider\")\n\tif len(channels) == 0 {\n\t\tt.Fatalf(\"provider 托管账号渠道丢失\")\n\t}\n\tfor _, accountChannel := range channels {\n\t\tchannel := accountChannel.Upstream\n\t\tif channel.BaseURL != templateBaseURL {\n\t\t\tt.Fatalf(\"%s 渠道主地址被手工地址覆盖: %q\", accountChannel.Kind, channel.BaseURL)\n\t\t}\n\t\tfor _, baseURL := range channel.GetAllBaseURLs() {\n\t\t\tif strings.Contains(baseURL, \"manual-pool\") {\n\t\t\t\tt.Fatalf(\"%s 渠道地址池混入手工地址: %v\", accountChannel.Kind, channel.GetAllBaseURLs())\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestProviderRouteNameAndPrimaryResult(t *testing.T) {\n\tbase := \"mimo-test\"\n\ttests := []struct {\n\t\troute config.ProviderRoute\n\t\twant string\n\t}{\n\t\t{route: config.ProviderRoute{ChannelKind: \"messages\"}, want: \"mimo-test-claude\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"chat\"}, want: \"mimo-test-chat\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"responses\"}, want: \"mimo-test-codex\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"gemini\"}, want: \"mimo-test-gemini\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := providerRouteName(base, tt.route, true); got != tt.want {\n\t\t\tt.Fatalf(\"providerRouteName(%s)=%q, want %q\", tt.route.ChannelKind, got, tt.want)\n\t\t}\n\t}\n\tif got := providerRouteName(base, config.ProviderRoute{ChannelKind: \"messages\"}, false); got != base {\n\t\tt.Fatalf(\"single route name=%q, want %q\", got, base)\n\t}\n\n\tresults := []AutoAddChannelResult{\n\t\t{ChannelKind: \"messages\", ChannelUID: \"ch_messages\", Index: 1},\n\t\t{ChannelKind: \"chat\", ChannelUID: \"ch_chat\", Index: 2},\n\t\t{ChannelKind: \"responses\", ChannelUID: \"ch_responses\", Index: 3},\n\t}\n\tprimary := primaryAutoAddResult(results, \"chat\")\n\tif primary.ChannelUID != \"ch_chat\" || primary.Index != 2 {\n\t\tt.Fatalf(\"primary=%+v, want chat result\", primary)\n\t}\n}\n\nfunc TestManagedAccountUIDForProviderUsesExistingAccount(t *testing.T) {\n\tcfg := config.Config{ManagedAccounts: []config.ManagedAccountConfig{\n\t\t{AccountUID: \"acct-old\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-new\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-deepseek\", ProviderID: \"deepseek\"},\n\t}}\n\tif got := managedAccountUIDForProvider(cfg, \"mimo\"); got != \"acct-new\" {\n\t\tt.Fatalf(\"mimo existing account = %q, want acct-new\", got)\n\t}\n\tif got := managedAccountUIDForProvider(cfg, \"unknown\"); got != \"\" {\n\t\tt.Fatalf(\"unknown provider 不应命中账号: %q\", got)\n\t}\n}\n\nfunc TestListManagedAccountsDoesNotExposeVolcengineSecret(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-secret\",\"volcengineAccessKey\":{\"accessKeyId\":\"AKIDEXAMPLE\",\"secretAccessKey\":\"SECRET_MUST_NOT_LEAK\",\"plan\":\"agent_plan\",\"planTier\":\"Large\",\"planStatus\":\"Running\"}}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"SECRET_MUST_NOT_LEAK\") || strings.Contains(body, \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", body)\n\t}\n\tfor _, expected := range []string{`\"hasVolcengineAccessKey\":true`, `\"volcenginePlan\":\"agent_plan\"`, `\"volcenginePlanTier\":\"Large\"`} {\n\t\tif !strings.Contains(body, expected) {\n\t\t\tt.Fatalf(\"响应缺少 %s: %s\", expected, body)\n\t\t}\n\t}\n}\n\nfunc TestKimiConsoleTokenLifecycleDoesNotExposeToken(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_kimi\",\"providerId\":\"kimi\",\"name\":\"kimi\",\"credentials\":[{\"credentialUid\":\"cred_kimi\",\"apiKey\":\"sk-kimi-secret\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_kimi\",\"channelUid\":\"ch_kimi\",\"providerId\":\"kimi\",\"name\":\"kimi\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.kimi.com/coding\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_kimi\",\"baseUrl\":\"https://api.kimi.com/coding\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tkimiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Authorization\") != \"Bearer web-session-secret\" {\n\t\t\tt.Fatalf(\"Kimi 请求未使用保存的会话令牌\")\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase kimiUsagesPath:\n\t\t\t_, _ = w.Write([]byte(`{\"usages\":[{\"scope\":\"FEATURE_CODING\",\"detail\":{\"limit\":\"100\",\"remaining\":\"80\",\"resetTime\":\"2026-07-27T16:13:43Z\"},\"limits\":[{\"window\":{\"duration\":300,\"timeUnit\":\"TIME_UNIT_MINUTE\"},\"detail\":{\"limit\":\"100\",\"remaining\":\"90\",\"resetTime\":\"2026-07-21T02:13:43Z\"}}]}],\"totalQuota\":{\"limit\":\"100\",\"remaining\":\"80\"}}`))\n\t\tcase kimiSubscriptionStatsPath:\n\t\t\t_, _ = w.Write([]byte(`{\"ratelimitCode5h\":{\"ratio\":0.1,\"enabled\":true},\"ratelimitCode7d\":{\"ratio\":0.2,\"enabled\":true},\"subscriptionBalance\":{\"feature\":\"FEATURE_OMNI\",\"type\":\"SUBSCRIPTION\",\"unit\":\"UNIT_CREDIT\",\"amountUsedRatio\":0.2,\"kimiCodeUsedRatio\":0.2,\"expireTime\":\"2026-08-20T16:14:07Z\"}}`))\n\t\tdefault:\n\t\t\thttp.NotFound(w, r)\n\t\t}\n\t}))\n\tdefer kimiServer.Close()\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: cfgManager,\n\t\tKimiConsoleClient: &KimiConsoleClient{\n\t\t\tHTTPClient: kimiServer.Client(), BaseURL: kimiServer.URL,\n\t\t},\n\t})\n\tput := httptest.NewRequest(\n\t\thttp.MethodPut,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token\",\n\t\tbytes.NewBufferString(`{\"accessToken\":\"Bearer web-session-secret\"}`),\n\t)\n\tput.Header.Set(\"Content-Type\", \"application/json\")\n\tputRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(putRecorder, put)\n\tif putRecorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"绑定 Kimi 令牌失败: status=%d body=%s\", putRecorder.Code, putRecorder.Body.String())\n\t}\n\tif strings.Contains(putRecorder.Body.String(), \"web-session-secret\") || strings.Contains(putRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"绑定响应泄露 Kimi 令牌: %s\", putRecorder.Body.String())\n\t}\n\tcredential, ok := cfgManager.GetManagedAccountCredential(\"acct_kimi\", \"cred_kimi\")\n\tif !ok || credential.KimiConsole == nil || credential.KimiConsole.AccessToken != \"web-session-secret\" {\n\t\tt.Fatalf(\"Kimi 令牌未持久化: %+v\", credential)\n\t}\n\tpersisted, err := os.ReadFile(configPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !strings.Contains(string(persisted), `\"accessToken\": \"web-session-secret\"`) {\n\t\tt.Fatalf(\"配置文件未保存 Kimi 令牌\")\n\t}\n\n\tlistRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(listRecorder, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif listRecorder.Code != http.StatusOK || strings.Contains(listRecorder.Body.String(), \"web-session-secret\") || strings.Contains(listRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"账号列表响应异常或泄露令牌: status=%d body=%s\", listRecorder.Code, listRecorder.Body.String())\n\t}\n\tif !strings.Contains(listRecorder.Body.String(), `\"hasKimiConsoleToken\":true`) || !strings.Contains(listRecorder.Body.String(), `\"kimiCodeUsage\"`) {\n\t\tt.Fatalf(\"账号列表缺少 Kimi 用量快照: %s\", listRecorder.Body.String())\n\t}\n\n\trefreshRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(refreshRecorder, httptest.NewRequest(\n\t\thttp.MethodPost,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token/refresh\",\n\t\tnil,\n\t))\n\tif refreshRecorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"刷新 Kimi 余量失败: status=%d body=%s\", refreshRecorder.Code, refreshRecorder.Body.String())\n\t}\n\tif strings.Contains(refreshRecorder.Body.String(), \"web-session-secret\") || strings.Contains(refreshRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"刷新响应泄露 Kimi 令牌: %s\", refreshRecorder.Body.String())\n\t}\n\n\tdeleteRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(deleteRecorder, httptest.NewRequest(\n\t\thttp.MethodDelete,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token\",\n\t\tnil,\n\t))\n\tif deleteRecorder.Code != http.StatusNoContent {\n\t\tt.Fatalf(\"解绑 Kimi 令牌失败: status=%d body=%s\", deleteRecorder.Code, deleteRecorder.Body.String())\n\t}\n\tcredential, _ = cfgManager.GetManagedAccountCredential(\"acct_kimi\", \"cred_kimi\")\n\tif credential.KimiConsole != nil {\n\t\tt.Fatalf(\"Kimi 令牌解绑后仍存在: %+v\", credential.KimiConsole)\n\t}\n\tpersisted, err = os.ReadFile(configPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(persisted), \"web-session-secret\") {\n\t\tt.Fatalf(\"解绑后配置文件仍包含 Kimi 令牌\")\n\t}\n}\n\nfunc TestSetVolcengineAccessKeyDetectsPlanBeforePersisting(t *testing.T) {\n\tcontrolPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Query().Get(\"Action\") != \"GetPersonalPlan\" {\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tvar body struct{ Plan string }\n\t\t_ = json.NewDecoder(r.Body).Decode(&body)\n\t\tif body.Plan == \"CodingPlan\" {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t_, _ = w.Write([]byte(`{\"ResponseMetadata\":{\"Error\":{\"Code\":\"ResourceNotFound.Plan\",\"Message\":\"not found\"}}}`))\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\"Result\":{\"PlanType\":\"Large\",\"Status\":\"Running\"}}`))\n\t}))\n\tdefer controlPlane.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-inference\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trunner := NewAutoDiscoveryRunner(nil, nil)\n\trunner.client = controlPlane.Client()\n\trunner.volcengineControlPlaneEndpoint = controlPlane.URL\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, Runner: runner})\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_volc/credentials/cred_volc/volcengine-access-key\", bytes.NewBufferString(`{\"accessKeyId\":\"AKID\",\"secretAccessKey\":\"SECRET\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"plan\":\"agent_plan\"`) {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_volc\", \"cred_volc\")\n\tif !ok || credential.VolcengineAccessKey == nil || credential.VolcengineAccessKey.SecretAccessKey != \"SECRET\" || credential.VolcengineAccessKey.Plan != \"agent_plan\" {\n\t\tt.Fatalf(\"凭证未正确持久化: %+v\", credential)\n\t}\n}\n\nfunc TestSetMiMoConsoleCookieRequiresConfirmationBeforeAdoptingKey(t *testing.T) {\n\tconst oldKey = \"tp-old-1234567890123456789012345678901234567890\"\n\tconst cookieKey = \"tp-new-1234567890123456789012345678901234567890\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != \"api-platform_serviceToken=session; userId=42\" {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase \"/api/v1/userProfile\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"userId\":\"42\"}}`))\n\t\tcase \"/api/v1/tokenPlan/detail\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"planCode\":\"max\",\"planName\":\"Max\",\"currentPeriodEnd\":\"2026-07-29 23:59:59\"}}`))\n\t\tcase \"/api/v1/tokenPlan/usage\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"monthUsage\":{\"percent\":0.25,\"items\":[{\"used\":25,\"limit\":100}]},\"usage\":{\"percent\":0.5,\"items\":[{\"used\":50,\"limit\":100}]}}}`))\n\t\tcase \"/api/v1/tokenPlan/apiKey/raw\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":\"` + cookieKey + `\"}`))\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}\n\t}))\n\tdefer console.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"credentials\":[{\"credentialUid\":\"cred_mimo\",\"apiKey\":\"` + oldKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_mimo\",\"channelUid\":\"ch_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_mimo\",\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\tdeps := &AutoManagedDeps{CfgManager: manager, MiMoConsoleClient: &MiMoConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL}}\n\trouter := setupAutoManagedRouter(deps)\n\tbody := `{\"cookie\":\"api-platform_serviceToken=session; userId=42\"}`\n\trequest := func(payload string) *httptest.ResponseRecorder {\n\t\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_mimo/credentials/cred_mimo/mimo-console-cookie\", bytes.NewBufferString(payload))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tw := httptest.NewRecorder()\n\t\trouter.ServeHTTP(w, req)\n\t\treturn w\n\t}\n\tw := request(body)\n\tif w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), `\"code\":\"mimo_cookie_key_mismatch\"`) {\n\t\tt.Fatalf(\"未要求确认: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ := manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != oldKey || credential.MiMoConsole != nil {\n\t\tt.Fatalf(\"确认前不应修改配置: %+v\", credential)\n\t}\n\tw = request(`{\"cookie\":\"api-platform_serviceToken=session; userId=42\",\"adoptCookieKey\":true}`)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"keyAdopted\":true`) {\n\t\tt.Fatalf(\"确认采用失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ = manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != cookieKey || credential.MiMoConsole == nil {\n\t\tt.Fatalf(\"确认后未采用 Cookie Key: %+v\", credential)\n\t}\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif strings.Contains(list.Body.String(), \"api-platform_serviceToken\") || strings.Contains(list.Body.String(), cookieKey) {\n\t\tt.Fatalf(\"账号列表泄漏 Cookie 或原始 Key: %s\", list.Body.String())\n\t}\n}\n\nfunc TestCompshareConsoleCookieBindingDoesNotExposeSecrets(t *testing.T) {\n\tconst currentKey = \"sk-cp-test-current\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != testCompshareCookie {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\n \"RetCode\": 0,\n \"UserPlans\": [{\n \"Code\": \"cppkg-test\", \"PlanCode\": \"cp-plan-test\", \"PlanName\": \"Pro\", \"DisplayName\": \"Pro 增强版\",\n \"LimitPer5h\": 3000, \"LimitPerWeek\": 7500, \"LimitPerMonth\": 19000,\n \"UsagePer5h\": 0, \"UsagePerWeek\": 100, \"UsagePerMonth\": 6496,\n \"ConcurrencyLimit\": 10, \"Status\": 1, \"ExpireAt\": 1785037981,\n \"Keys\": [{\"APIKey\": \"` + currentKey + `\"}]\n }]\n}`))\n\t}))\n\tdefer console.Close()\n\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_compshare\",\"providerId\":\"compshare\",\"name\":\"compshare\",\"credentials\":[{\"credentialUid\":\"cred_compshare\",\"apiKey\":\"` + currentKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_compshare\",\"channelUid\":\"ch_compshare\",\"providerId\":\"compshare\",\"name\":\"compshare\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://api.compshare.cn/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_compshare\",\"baseUrl\":\"https://api.compshare.cn/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: manager,\n\t\tCompshareConsoleClient: &CompshareConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL},\n\t})\n\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_compshare/credentials/cred_compshare/compshare-console-cookie\", bytes.NewBufferString(`{\"cookie\":\"`+testCompshareCookie+`\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"displayName\":\"Pro 增强版\"`) {\n\t\tt.Fatalf(\"绑定失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_compshare\", \"cred_compshare\")\n\tif !ok || credential.CompshareConsole == nil || credential.CompshareConsole.Cookie != testCompshareCookie {\n\t\tt.Fatalf(\"控制台套餐未持久化: %+v\", credential)\n\t}\n\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tresponse := list.Body.String()\n\tfor _, secret := range []string{\"U_JWT_TOKEN\", \"test-session\", \"test_user@console.compshare.cn\", \"org-test\", currentKey, \"cppkg-test\"} {\n\t\tif strings.Contains(response, secret) {\n\t\t\tt.Fatalf(\"账号列表泄漏敏感字段 %q: %s\", secret, response)\n\t\t}\n\t}\n\tfor _, expected := range []string{`\"hasCompshareConsoleCookie\":true`, `\"planCode\":\"cp-plan-test\"`, `\"monthlyUsage\":{\"used\":6496,\"limit\":19000`} {\n\t\tif !strings.Contains(response, expected) {\n\t\t\tt.Fatalf(\"账号列表缺少套餐字段 %s: %s\", expected, response)\n\t\t}\n\t}\n}\n\nfunc TestProviderAutoAddReusesExistingAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(`{\"providerId\":\"mimo\",\"apiKeys\":[\"sk-existing\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"追加已有 provider key status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 4 {\n\t\tt.Fatalf(\"旧账号应自动补齐模板中的协议渠道: %+v\", channels)\n\t}\n\tkinds := make(map[string]bool, len(channels))\n\tfor _, channel := range channels {\n\t\tkinds[channel.Kind] = true\n\t}\n\tfor _, kind := range []string{\"messages\", \"chat\", \"responses\", \"gemini\"} {\n\t\tif !kinds[kind] {\n\t\t\tt.Fatalf(\"旧账号缺少 %s route: %+v\", kind, channels)\n\t\t}\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.AccountUID != \"acct_test\" {\n\t\tt.Fatalf(\"响应未返回已有账号: body=%s err=%v\", w.Body.String(), err)\n\t}\n\tif len(response.Channels) != 4 {\n\t\tt.Fatalf(\"响应未返回补齐后的全部渠道: %+v\", response.Channels)\n\t}\n}\n\nfunc TestMissingProviderAccountRoutes(t *testing.T) {\n\ttmpl, ok := config.GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"缺少 deepseek provider 模板\")\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{ServiceType: \"claude\"},\n\t}}\n\tmissing := missingProviderAccountRoutes(tmpl, existing)\n\tif len(missing) != 2 || missing[0].ChannelKind != \"chat\" || missing[1].ChannelKind != \"responses\" {\n\t\tt.Fatalf(\"missing routes = %+v\", missing)\n\t}\n\tconfigs, baseURLs, err := bindProviderRouteKeys(tmpl, missing[0], []string{\"sk-existing\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(configs) != 1 || configs[0].BaseURL != \"https://api.deepseek.com\" || len(baseURLs) != 1 {\n\t\tt.Fatalf(\"route 绑定结果不正确: configs=%+v baseURLs=%v\", configs, baseURLs)\n\t}\n}\n\nfunc TestBindProviderRouteKeysPreservesExistingEndpointAffinity(t *testing.T) {\n\ttests := []struct {\n\t\tproviderID string\n\t\tapiKey string\n\t\texistingBaseURL string\n\t\ttargetKind string\n\t\twantBaseURL string\n\t}{\n\t\t{\n\t\t\tproviderID: \"mimo\", apiKey: \"tp-existing\",\n\t\t\texistingBaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\t\ttargetKind: \"chat\", wantBaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\",\n\t\t},\n\t\t{\n\t\t\tproviderID: \"volcengine\", apiKey: \"ark-existing\",\n\t\t\texistingBaseURL: \"https://ark.cn-beijing.volces.com/api/coding\",\n\t\t\ttargetKind: \"responses\", wantBaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.providerID, func(t *testing.T) {\n\t\t\ttmpl, ok := config.GetProviderTemplate(tt.providerID)\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"缺少 provider 模板: %s\", tt.providerID)\n\t\t\t}\n\t\t\texisting := []config.AccountChannel{{\n\t\t\t\tKind: \"messages\",\n\t\t\t\tUpstream: config.UpstreamConfig{\n\t\t\t\t\tProviderID: tt.providerID, ServiceType: \"claude\", APIKeys: []string{tt.apiKey},\n\t\t\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: tt.apiKey, BaseURL: tt.existingBaseURL}},\n\t\t\t\t},\n\t\t\t}}\n\t\t\tvar target config.ProviderRoute\n\t\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\t\tif route.ChannelKind == tt.targetKind {\n\t\t\t\t\ttarget = route\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tconfigs, _, err := bindProviderRouteKeysWithAffinities(\n\t\t\t\ttmpl, target, []string{tt.apiKey}, providerKeyCandidateAffinities(tmpl, existing),\n\t\t\t)\n\t\t\tif err != nil || len(configs) != 1 || configs[0].BaseURL != tt.wantBaseURL {\n\t\t\t\tt.Fatalf(\"亲和绑定错误: configs=%+v err=%v want=%s\", configs, err, tt.wantBaseURL)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPlanProviderAccountRouteAdditionsRejectsNewKeyBeforeMutation(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t}))\n\tdefer server.Close()\n\n\ttmpl := &config.ProviderTemplate{\n\t\tProviderID: \"test-provider\",\n\t\tRoutes: []config.ProviderRoute{\n\t\t\t{ChannelKind: \"messages\", ServiceType: \"claude\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/anthropic\"}}},\n\t\t\t{ChannelKind: \"chat\", ServiceType: \"openai\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/v1\"}}},\n\t\t},\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct_test\", ChannelUID: \"ch_messages\", Name: \"test-provider\",\n\t\t\tProviderID: \"test-provider\", ServiceType: \"claude\", AutoManaged: true, APIKeys: []string{\"sk-existing\"},\n\t\t},\n\t}}\n\tadditions, status, err := planProviderAccountRouteAdditions(\n\t\tt.Context(), config.Config{}, tmpl, \"acct_test\", []string{\"sk-existing\", \"sk-invalid\"}, existing,\n\t)\n\tif err == nil || status != http.StatusBadRequest || len(additions) != 0 {\n\t\tt.Fatalf(\"无效新 Key 应在生成新增 route 前失败: status=%d additions=%+v err=%v\", status, additions, err)\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}, "8cb1f08da90e4858885f246ddadd488390fdba08": {"t": 1786068377.065793, "n": 68677, "h": "9a46edf960bd44dc", "c": "package autopilot\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/BenedictKing/ccx/internal/errutil\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupAutoManagedRouter(deps *AutoManagedDeps) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\t// 直接在根引擎注册路由,模拟 apiGroup 注册\n\tRegisterAutoManagedRoutes(r.Group(\"/api\"), deps)\n\treturn r\n}\n\nfunc TestListAccountsMasksCredentials(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-secret-value\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil)\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"sk-secret-value\") {\n\t\tt.Fatalf(\"账号列表泄露明文 Key: %s\", body)\n\t}\n\tif !strings.Contains(body, \"cred_test\") || !strings.Contains(body, \"keyMask\") {\n\t\tt.Fatalf(\"账号列表缺少凭证掩码信息: %s\", body)\n\t}\n}\n\nfunc TestListAccountsIncludesActiveEndpointModelAvailability(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"volcengine\",\"name\":\"volcengine\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"ark-secret-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"ark-secret-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"volcengine\",\"name\":\"volcengine-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://example.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://example.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://example.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tstore, err := NewProfileStoreWithDB(newTestDB(t))\n\tif err != nil {\n\t\tt.Fatalf(\"NewProfileStoreWithDB 失败: %v\", err)\n\t}\n\tupdatedAt := time.Date(2026, 7, 17, 10, 54, 33, 0, time.UTC)\n\tdiscoveredAt := updatedAt\n\tdiscoveredAtLater := updatedAt.Add(time.Minute)\n\tfor _, profile := range []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_a\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_a\", KeyMask: \"ark-a***001\", AvailableModels: []string{\"model-b\", \"model-a\"}, UpdatedAt: updatedAt,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI, ModelsDiscoveredAt: &discoveredAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_b\", AccountUID: \"acct_test\", ChannelUID: \"ch_messages\",\n\t\t\tCredentialUID: \"cred_b\", KeyMask: \"ark-b***002\", AvailableModels: []string{\"model-c\", \"model-b\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI, ModelsDiscoveredAt: &discoveredAtLater,\n\t\t},\n\t} {\n\t\tif err := store.Upsert(profile); err != nil {\n\t\t\tt.Fatalf(\"ProfileStore.Upsert 失败: %v\", err)\n\t\t}\n\t}\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager, Runner: runner})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response struct {\n\t\tAccounts []managedAccountView `json:\"accounts\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatalf(\"解析响应失败: %v\", err)\n\t}\n\tif len(response.Accounts) != 1 || len(response.Accounts[0].Channels) != 1 {\n\t\tt.Fatalf(\"账号渠道数量错误: %+v\", response.Accounts)\n\t}\n\tchannel := response.Accounts[0].Channels[0]\n\tif got, want := strings.Join(channel.DiscoveredModels, \",\"), \"model-a,model-b,model-c\"; got != want {\n\t\tt.Fatalf(\"DiscoveredModels=%q, want %q\", got, want)\n\t}\n\tif !channel.ModelInventoryKnown {\n\t\tt.Fatal(\"已发现模型时 ModelInventoryKnown 应为 true\")\n\t}\n\tif len(channel.ModelBindings) != 2 {\n\t\tt.Fatalf(\"ModelBindings=%+v, want 2\", channel.ModelBindings)\n\t}\n\tif got := strings.Join(channel.ModelBindings[0].Models, \",\"); got != \"model-a,model-b\" {\n\t\tt.Fatalf(\"第一个 Key 模型=%q\", got)\n\t}\n\tif channel.ModelsUpdatedAt == nil || !channel.ModelsUpdatedAt.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"ModelsUpdatedAt=%v\", channel.ModelsUpdatedAt)\n\t}\n\tif strings.Contains(w.Body.String(), \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", w.Body.String())\n\t}\n}\n\nfunc TestListAccountsIncludesCustomAutoManagedProtocolAvailability(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [],\n \"upstream\": [], \"chatUpstream\": [],\n \"responsesUpstream\": [{\"accountUid\":\"acct_custom\",\"channelUid\":\"ch_custom\",\"name\":\"custom-endpoint\",\"serviceType\":\"responses\",\"baseUrl\":\"https://example.com\",\"apiKeys\":[\"sk-custom-secret\"],\"apiKeyConfigs\":[{\"key\":\"sk-custom-secret\",\"credentialUid\":\"cred_custom\",\"baseUrl\":\"https://example.com\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tstore, err := NewProfileStoreWithDB(newTestDB(t))\n\tif err != nil {\n\t\tt.Fatalf(\"NewProfileStoreWithDB 失败: %v\", err)\n\t}\n\tdiscoveredAt := time.Date(2026, 7, 25, 5, 11, 29, 0, time.UTC)\n\tprofile := &KeyEndpointProfile{\n\t\tEndpointUID: \"ep_custom\", AccountUID: \"acct_custom\", ChannelUID: \"ch_custom\",\n\t\tCredentialUID: \"cred_custom\", KeyMask: \"sk-c***ret\", ServiceType: \"responses\",\n\t\tAvailableModels: []string{\"gpt-5.6-sol\"}, ModelsDiscoveredAt: &discoveredAt,\n\t\tModelDiscoverySource: ModelDiscoverySourceModelsAPI,\n\t\tProtocolModels: map[string][]string{\n\t\t\t\"messages\": {\"gpt-5.6-sol\"},\n\t\t\t\"chat\": {\"gpt-5.6-sol\"},\n\t\t\t\"responses\": {\"gpt-5.6-sol\"},\n\t\t},\n\t\tProtocolDiscoveredAt: map[string]time.Time{\n\t\t\t\"messages\": discoveredAt, \"chat\": discoveredAt, \"responses\": discoveredAt,\n\t\t},\n\t\tProtocolDiscoverySource: map[string]string{\n\t\t\t\"messages\": \"protocol_probe\", \"chat\": \"protocol_probe\", \"responses\": \"models_api\",\n\t\t},\n\t\tUpdatedAt: discoveredAt,\n\t}\n\tif err := store.Upsert(profile); err != nil {\n\t\tt.Fatalf(\"ProfileStore.Upsert 失败: %v\", err)\n\t}\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: cfgManager,\n\t\tRunner: NewAutoDiscoveryRunner(store, nil),\n\t})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"GET /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tif strings.Contains(w.Body.String(), \"sk-custom-secret\") {\n\t\tt.Fatalf(\"账号列表泄露自定义渠道明文 Key: %s\", w.Body.String())\n\t}\n\n\tvar response struct {\n\t\tAccounts []managedAccountView `json:\"accounts\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatalf(\"解析响应失败: %v\", err)\n\t}\n\tif len(response.Accounts) != 1 {\n\t\tt.Fatalf(\"Accounts=%+v, want 1\", response.Accounts)\n\t}\n\taccount := response.Accounts[0]\n\tif account.AccountUID != \"acct_custom\" || len(account.Credentials) != 1 || len(account.Channels) != 1 {\n\t\tt.Fatalf(\"自定义自动托管账号聚合错误: %+v\", account)\n\t}\n\tprotocols := make([]string, 0, len(account.Channels[0].ProtocolAvailability))\n\tfor _, availability := range account.Channels[0].ProtocolAvailability {\n\t\tprotocols = append(protocols, availability.Protocol)\n\t}\n\tif got, want := strings.Join(protocols, \",\"), \"messages,chat,responses\"; got != want {\n\t\tt.Fatalf(\"ProtocolAvailability=%q, want %q\", got, want)\n\t}\n}\n\nfunc TestManagedChannelModelAvailabilityIncludesEmptyAutoDiscoveryResult(t *testing.T) {\n\tupdatedAt := time.Date(2026, 7, 17, 11, 2, 0, 0, time.UTC)\n\tmodels, bindings, latest, known := managedChannelModelAvailability([]*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_empty\", CredentialUID: \"cred_empty\", KeyMask: \"ark-e***001\",\n\t\t\tSource: \"auto_discovery\", AvailableModels: []string{}, UpdatedAt: updatedAt,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_models\", CredentialUID: \"cred_models\", KeyMask: \"ark-m***002\",\n\t\t\tAvailableModels: []string{\"model-a\"}, UpdatedAt: updatedAt.Add(time.Minute),\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_unknown\", CredentialUID: \"cred_unknown\", KeyMask: \"ark-u***003\",\n\t\t\tSource: \"l1_passive\", UpdatedAt: updatedAt.Add(2 * time.Minute),\n\t\t},\n\t})\n\n\tif !known {\n\t\tt.Fatal(\"自动发现的空模型清单仍应标记为已知\")\n\t}\n\tif got, want := strings.Join(models, \",\"), \"model-a\"; got != want {\n\t\tt.Fatalf(\"models=%q, want %q\", got, want)\n\t}\n\tif len(bindings) != 2 {\n\t\tt.Fatalf(\"bindings=%+v, want only two known inventories\", bindings)\n\t}\n\tif got := bindings[0]; got.KeyMask != \"ark-e***001\" || len(got.Models) != 0 {\n\t\tt.Fatalf(\"empty discovery binding=%+v, want empty ark-e binding\", got)\n\t}\n\tif latest == nil || !latest.Equal(updatedAt.Add(time.Minute)) {\n\t\tt.Fatalf(\"latest=%v, want discovered inventory update time\", latest)\n\t}\n}\n\nfunc TestManagedChannelModelAvailabilityIncludesDiscoveryMetadata(t *testing.T) {\n\tfirst := time.Date(2026, 7, 22, 0, 42, 12, 0, time.UTC)\n\tsecond := first.Add(time.Minute)\n\tinventory := managedChannelModelAvailabilityDetails([]*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep-a\",\n\t\t\tCredentialUID: \"cred-a\",\n\t\t\tKeyMask: \"ark-a***001\",\n\t\t\tAvailableModels: []string{\"glm-5.2\"},\n\t\t\tUpdatedAt: first,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceControlPlane,\n\t\t\tModelDiscoveryMessage: \"火山管控面 Coding Plan 模型清单\",\n\t\t\tModelsDiscoveredAt: &first,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep-b\",\n\t\t\tCredentialUID: \"cred-b\",\n\t\t\tKeyMask: \"ark-b***002\",\n\t\t\tAvailableModels: []string{\"deepseek-v4-pro\"},\n\t\t\tUpdatedAt: second,\n\t\t\tModelDiscoverySource: ModelDiscoverySourceBuiltinFallback,\n\t\t\tModelDiscoveryMessage: \"管控面失败,已回退内置模型清单\",\n\t\t\tModelsDiscoveredAt: &second,\n\t\t},\n\t})\n\n\tif !inventory.known || inventory.source != \"mixed\" {\n\t\tt.Fatalf(\"inventory source=%q known=%v\", inventory.source, inventory.known)\n\t}\n\tif !inventory.latestDiscoveredAt.Equal(second) || inventory.message != \"管控面失败,已回退内置模型清单\" {\n\t\tt.Fatalf(\"inventory discovery metadata=%+v\", inventory)\n\t}\n\tif len(inventory.bindings) != 2 || inventory.bindings[1].ModelsDiscoveredAt == nil {\n\t\tt.Fatalf(\"binding discovery metadata=%+v\", inventory.bindings)\n\t}\n}\n\nfunc TestManagedProtocolAvailabilityViewsAggregatesByProtocolAndCredential(t *testing.T) {\n\tdiscoveredAt := time.Date(2026, 7, 25, 10, 0, 0, 0, time.UTC)\n\tprofiles := []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep-a\", CredentialUID: \"cred-a\", KeyMask: \"sk-a***001\", ServiceType: \"responses\",\n\t\t\tUpdatedAt: discoveredAt, ProtocolModels: map[string][]string{\n\t\t\t\t\"responses\": {\"shared\", \"responses-only\"},\n\t\t\t\t\"chat\": {\"shared\"},\n\t\t\t},\n\t\t\tProtocolDiscoveredAt: map[string]time.Time{\"responses\": discoveredAt, \"chat\": discoveredAt},\n\t\t\tProtocolDiscoverySource: map[string]string{\"responses\": \"models_api\", \"chat\": \"protocol_probe\"},\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep-b\", CredentialUID: \"cred-b\", KeyMask: \"sk-b***002\", ServiceType: \"responses\",\n\t\t\tUpdatedAt: discoveredAt, ProtocolModels: map[string][]string{\n\t\t\t\t\"responses\": {\"shared\"},\n\t\t\t\t\"messages\": {\"shared\", \"messages-only\"},\n\t\t\t},\n\t\t\tProtocolDiscoveredAt: map[string]time.Time{\"responses\": discoveredAt, \"messages\": discoveredAt},\n\t\t\tProtocolDiscoverySource: map[string]string{\"responses\": \"models_api\", \"messages\": \"protocol_probe\"},\n\t\t},\n\t}\n\n\tviews := managedProtocolAvailabilityViews(profiles)\n\tif len(views) != 3 {\n\t\tt.Fatalf(\"views=%+v\", views)\n\t}\n\tif views[0].Protocol != \"messages\" || strings.Join(views[0].DiscoveredModels, \",\") != \"messages-only,shared\" {\n\t\tt.Fatalf(\"messages view=%+v\", views[0])\n\t}\n\tif views[2].Protocol != \"responses\" || len(views[2].ModelBindings) != 2 {\n\t\tt.Fatalf(\"responses view=%+v\", views[2])\n\t}\n}\n\nfunc TestPatchAccountCredentialsRemovesByUID(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_a\",\"apiKey\":\"sk-a\"},{\"credentialUid\":\"cred_b\",\"apiKey\":\"sk-b\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"baseUrls\":[\"https://api.xiaomimimo.com/anthropic\"],\"apiKeyConfigs\":[{\"credentialUid\":\"cred_a\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"},{\"credentialUid\":\"cred_b\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatalf(\"写测试配置失败: %v\", err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatalf(\"NewConfigManager 失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPatch, \"/api/accounts/acct_test/credentials\", bytes.NewBufferString(`{\"removeCredentialUids\":[\"cred_b\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PATCH credentials status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 1 || len(channels[0].Upstream.APIKeys) != 1 || channels[0].Upstream.APIKeys[0] != \"sk-a\" {\n\t\tt.Fatalf(\"按 credentialUid 删除失败: %+v\", channels)\n\t}\n}\n\nfunc TestAutoAddRequest_Binding(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"完整请求\",\n\t\t\tbody: `{\"name\":\"test\",\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"自动生成名称\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test123\"]}`,\n\t\t},\n\t\t{\n\t\t\tname: \"多 URL 和多 Key\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api1.example.com\",\"https://api2.example.com\"],\"apiKeys\":[\"sk-key1\",\"sk-key2\"]}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\tif err := decoder.Decode(&req); err != nil {\n\t\t\t\tif !tt.wantErr {\n\t\t\t\t\tt.Fatalf(\"解码失败: %v\", err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\tt.Fatal(\"baseUrls 不应为空\")\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\tt.Fatal(\"apiKeys 不应为空\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAutoAddRequest_Validation(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tbody string\n\t\terr string\n\t}{\n\t\t{\n\t\t\tname: \"空 baseUrls\",\n\t\t\tbody: `{\"baseUrls\":[],\"apiKeys\":[\"sk-test\"]}`,\n\t\t\terr: \"baseUrls 不能为空\",\n\t\t},\n\t\t{\n\t\t\tname: \"空 apiKeys\",\n\t\t\tbody: `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[]}`,\n\t\t\terr: \"apiKeys 不能为空\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar req AutoAddRequest\n\t\t\tr := httptest.NewRequest(http.MethodPost, \"/\", bytes.NewBufferString(tt.body))\n\t\t\tr.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\t\tdecoder := json.NewDecoder(r.Body)\n\t\t\t_ = decoder.Decode(&req) // 忽略解码错误,关注业务校验\n\n\t\t\tif len(req.BaseURLs) == 0 {\n\t\t\t\t// 这就是期望的行为\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(req.APIKeys) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tt.Fatalf(\"期望验证失败: %s\", tt.err)\n\t\t})\n\t}\n}\n\nfunc TestInferAutoAddProviderID(t *testing.T) {\n\tzhipuKey := \"0123456789abcdef0123456789abcdef.ABCDEFGHIJKLMNO1\"\n\ttests := []struct {\n\t\tname string\n\t\tbaseURLs []string\n\t\tapiKeys []string\n\t\twant string\n\t}{\n\t\t{name: \"官方 Claude URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"官方 OpenAI URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4/\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"两个官方协议 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/anthropic\", \"https://open.bigmodel.cn/api/paas/v4\"}, apiKeys: []string{\"sk-any\"}, want: \"glm\"},\n\t\t{name: \"仅智谱 Key\", apiKeys: []string{zhipuKey}, want: \"glm\"},\n\t\t{name: \"第三方 URL 不按 Key 提升\", baseURLs: []string{\"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"混合官方与第三方 URL\", baseURLs: []string{\"https://open.bigmodel.cn/api/paas/v4\", \"https://relay.example/v1\"}, apiKeys: []string{zhipuKey}},\n\t\t{name: \"共享 sk Key 无法推断\", apiKeys: []string{\"sk-abcdefghijklmnopqrstuvwxyz123456\"}},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := inferAutoAddProviderID(tt.baseURLs, tt.apiKeys); got != tt.want {\n\t\t\t\tt.Fatalf(\"inferAutoAddProviderID() = %q, want %q\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestApplyProviderUpstreamDefaults(t *testing.T) {\n\t// shouldPassbackReasoningContentByDefault 按 ProviderID+ServiceType 静态推导(GLM 已知\n\t// 协议事实),ApplyProviderUpstreamDefaults 不再写字段,测试必须设置 ProviderID 才能命中。\n\tglmChat := config.UpstreamConfig{ProviderID: \"glm\", ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmChat)\n\tif glmChat.ReasoningParamStyle != \"reasoning_effort\" || !glmChat.IsPassbackReasoningContentEnabled() {\n\t\tt.Fatalf(\"GLM OpenAI 默认兼容参数未补齐: %+v\", glmChat)\n\t}\n\n\tglmClaude := config.UpstreamConfig{ProviderID: \"glm\", ServiceType: \"claude\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"glm\", &glmClaude)\n\tif glmClaude.ReasoningParamStyle != \"\" || glmClaude.IsPassbackReasoningContentEnabled() {\n\t\tt.Fatalf(\"GLM Claude 原生 route 不应注入 OpenAI 参数: %+v\", glmClaude)\n\t}\n\n\tcustom := config.UpstreamConfig{ServiceType: \"openai\"}\n\tconfig.ApplyProviderUpstreamDefaults(\"\", &custom)\n\tif custom.ReasoningParamStyle != \"\" || custom.IsPassbackReasoningContentEnabled() {\n\t\tt.Fatalf(\"自定义渠道不应应用 GLM 默认值: %+v\", custom)\n\t}\n}\n\nfunc TestAutoStatusResponse_Serialization(t *testing.T) {\n\tnow := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)\n\tresp := AutoStatusResponse{\n\t\tAutoManaged: true,\n\t\tAutoManagedAt: &now,\n\t\tDiscovery: &DiscoveryStatusInfo{\n\t\t\tStatus: DiscoveryStatusDone,\n\t\t\tStartedAt: &now,\n\t\t\tEndpoints: []EndpointDiscoveryInfo{\n\t\t\t\t{\n\t\t\t\t\tKeyMask: \"sk-****abcd\",\n\t\t\t\t\tBaseURL: \"https://api.example.com\",\n\t\t\t\t\tModelsCount: 5,\n\t\t\t\t\tProtocolOk: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tdata, err := json.Marshal(resp)\n\tif err != nil {\n\t\tt.Fatalf(\"序列化失败: %v\", err)\n\t}\n\n\tvar parsed map[string]interface{}\n\tif err := json.Unmarshal(data, &parsed); err != nil {\n\t\tt.Fatalf(\"反序列化失败: %v\", err)\n\t}\n\n\tif parsed[\"autoManaged\"] != true {\n\t\tt.Fatalf(\"期望 autoManaged=true, 实际=%v\", parsed[\"autoManaged\"])\n\t}\n\n\tdiscovery := parsed[\"discovery\"].(map[string]interface{})\n\tif discovery[\"status\"] != \"done\" {\n\t\tt.Fatalf(\"期望 status=done, 实际=%v\", discovery[\"status\"])\n\t}\n\n\tendpoints := discovery[\"endpoints\"].([]interface{})\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"期望 1 个 endpoint, 实际=%d\", len(endpoints))\n\t}\n\tep := endpoints[0].(map[string]interface{})\n\tif ep[\"keyMask\"] != \"sk-****abcd\" {\n\t\tt.Fatalf(\"期望 keyMask=sk-****abcd, 实际=%v\", ep[\"keyMask\"])\n\t}\n}\n\nfunc TestAutoStatusResponse_KeyMaskNoPlaintext(t *testing.T) {\n\t// 验证 key mask 不会泄露明文 key\n\tresult := EndpointDiscoveryInfo{\n\t\tKeyMask: \"sk-****abcd\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tModelsCount: 3,\n\t\tProtocolOk: true,\n\t}\n\n\tdata, _ := json.Marshal(result)\n\ts := string(data)\n\n\t// 不应包含常见的 key 前缀(如果 key 以 sk- 开头)\n\tif s == \"sk-test1234567890\" {\n\t\tt.Fatal(\"key mask 包含明文 key\")\n\t}\n}\n\nfunc TestValidChannelKinds(t *testing.T) {\n\texpected := []string{\"messages\", \"chat\", \"responses\", \"gemini\", \"images\", \"vectors\"}\n\tfor _, kind := range expected {\n\t\tif !validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应有效\", kind)\n\t\t}\n\t}\n\n\tinvalid := []string{\"unknown\", \"test\", \"\"}\n\tfor _, kind := range invalid {\n\t\tif validChannelKinds[kind] {\n\t\t\tt.Fatalf(\"渠道类型 %s 应无效\", kind)\n\t\t}\n\t}\n}\n\nfunc TestKindToDefaultServiceType(t *testing.T) {\n\ttests := []struct {\n\t\tkind string\n\t\texpected string\n\t}{\n\t\t{\"messages\", \"claude\"},\n\t\t{\"chat\", \"openai\"},\n\t\t{\"responses\", \"responses\"},\n\t\t{\"gemini\", \"gemini\"},\n\t\t{\"images\", \"openai\"},\n\t\t{\"vectors\", \"openai\"},\n\t\t{\"unknown\", \"openai\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.kind, func(t *testing.T) {\n\t\t\tresult := kindToDefaultServiceType(tt.kind)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Fatalf(\"kind=%s, 期望=%s, 实际=%s\", tt.kind, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCustomAutoAddResponseIncludesActualRoute(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\tdiscoverer := NewRateLimitDiscoverer(RateLimitDiscovererConfig{PassiveAimdEnabled: true, QuietLogs: true})\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, RateLimitDiscoverer: discoverer})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"fastaitoken-com-test\",\"baseUrls\":[\"https://example.com\"],\"apiKeys\":[\"sk-test\"],\"rateLimitHint\":{\"initialRpm\":30,\"effectiveRpm\":15,\"rateLimited\":true,\"rateLimitedCount\":1}}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 {\n\t\tt.Fatalf(\"channels=%+v, want one actual route\", response.Channels)\n\t}\n\troute := response.Channels[0]\n\tif route.ChannelKind != \"responses\" || route.ServiceType != \"responses\" || route.ChannelUID != response.ChannelUID {\n\t\tt.Fatalf(\"route=%+v response=%+v\", route, response)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.ResponsesUpstream) != 1 || len(cfg.Upstream) != 0 {\n\t\tt.Fatalf(\"custom route persisted in wrong channel: responses=%d messages=%d\", len(cfg.ResponsesUpstream), len(cfg.Upstream))\n\t}\n\tcreated := cfg.ResponsesUpstream[0]\n\tbaseURL := created.GetEffectiveBaseURL()\n\tendpointUID := GenerateEndpointUID(created.ChannelUID, baseURL, KeyHashFromAPIKey(\"sk-test\"))\n\tif suggestion := discoverer.SuggestedLimit(endpointUID); suggestion.RPM != 15 || suggestion.Confidence != 0.7 {\n\t\tt.Fatalf(\"rate limit suggestion=%+v, want rpm=15 confidence=0.7\", suggestion)\n\t}\n\n\tstore := newTestProfileStore(t)\n\trunner := NewAutoDiscoveryRunner(store, nil)\n\tseedAutoAddRateLimitHint(\n\t\t&AutoManagedDeps{CfgManager: manager, Runner: runner, RateLimitDiscoverer: discoverer},\n\t\tmanager.GetAccountChannels(response.AccountUID),\n\t\t&AutoAddRateLimitHint{InitialRPM: 30, EffectiveRPM: 15, RateLimited: true, RateLimitedCount: 1},\n\t\t\"sk-test\",\n\t\t\"https://example.com\",\n\t)\n\tprofile := store.Get(endpointUID)\n\tif profile == nil || profile.DiscoveredRPM != 15 || profile.RateLimitConfidence != 0.7 || profile.RateLimitSource != string(RateLimitSourcePassiveAIMD) {\n\t\tt.Fatalf(\"rate limit profile=%+v\", profile)\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyToEquivalentExistingBaseURL(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [],\n \"chatUpstream\": [{\"channelUid\":\"ch_existing\",\"name\":\"localhost-old-name\",\"serviceType\":\"openai\",\"baseUrl\":\"http://localhost:8990/v1\",\"apiKeys\":[\"sk-old\"],\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(\n\t\t`{\"name\":\"localhost-8990\",\"baseUrls\":[\"http://localhost:8990/\"],\"apiKeys\":[\"sk-new\"]}`,\n\t))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 1 || response.Channels[0].Name != \"localhost-old-name\" {\n\t\tt.Fatalf(\"应返回已有渠道: %+v\", response.Channels)\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 0 || len(cfg.ChatUpstream) != 1 {\n\t\tt.Fatalf(\"重复请求不应创建新渠道: messages=%d chat=%d\", len(cfg.Upstream), len(cfg.ChatUpstream))\n\t}\n\tif got := strings.Join(cfg.ChatUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"已有渠道密钥=%q, want sk-old,sk-new\", got)\n\t}\n}\n\nfunc TestFindExistingAutoAddChannelsPreservesHashSemantics(t *testing.T) {\n\tcfg := config.Config{\n\t\tChatUpstream: []config.UpstreamConfig{{\n\t\t\tChannelUID: \"ch-hash\", ServiceType: \"openai\", BaseURL: \"https://example.com#\",\n\t\t}},\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com\"}); len(matches) != 0 {\n\t\tt.Fatalf(\"普通 Base URL 不应匹配禁止版本前缀的地址: %+v\", matches)\n\t}\n\tif matches := findExistingAutoAddChannels(cfg, []string{\"https://example.com/#\"}); len(matches) != 1 {\n\t\tt.Fatalf(\"等效 # 地址匹配数量=%d, want 1\", len(matches))\n\t}\n}\n\nfunc TestCustomAutoAddAppendsKeyAndMissingRouteToManagedAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [{\"accountUid\":\"acct_existing\",\"channelUid\":\"ch_existing\",\"name\":\"localhost-8990\",\"serviceType\":\"claude\",\"baseUrl\":\"http://localhost:8990\",\"apiKeys\":[\"sk-old\"],\"autoManaged\":true,\"status\":\"active\"}],\n \"chatUpstream\": [], \"responsesUpstream\": [], \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"localhost-8990\",\n \"baseUrls\":[\"http://localhost:8990/v1\"],\n \"apiKeys\":[\"sk-new\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"claude-test\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"gpt-test\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d\", len(cfg.Upstream), len(cfg.ResponsesUpstream))\n\t}\n\tif cfg.Upstream[0].AccountUID != \"acct_existing\" || cfg.ResponsesUpstream[0].AccountUID != \"acct_existing\" {\n\t\tt.Fatalf(\"应复用已有账号: messages=%q responses=%q\", cfg.Upstream[0].AccountUID, cfg.ResponsesUpstream[0].AccountUID)\n\t}\n\tif got := strings.Join(cfg.Upstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"messages 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif got := strings.Join(cfg.ResponsesUpstream[0].APIKeys, \",\"); got != \"sk-old,sk-new\" {\n\t\tt.Fatalf(\"responses 密钥=%q, want sk-old,sk-new\", got)\n\t}\n\tif cfg.Upstream[0].Name != \"localhost-8990-claude\" || cfg.ResponsesUpstream[0].Name != \"localhost-8990-codex\" {\n\t\tt.Fatalf(\"多协议自动命名错误: messages=%q responses=%q\", cfg.Upstream[0].Name, cfg.ResponsesUpstream[0].Name)\n\t}\n}\n\nfunc TestCustomAutoAddCreatesAllDetectedRoutesWithProtocolModels(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"upstream\": [], \"chatUpstream\": [], \"responsesUpstream\": [],\n \"geminiUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tbody := `{\n \"name\":\"fastaitoken-com-test\",\n \"baseUrls\":[\"https://example.com/keys\"],\n \"apiKeys\":[\"sk-test\"],\n \"routes\":[\n {\"channelKind\":\"messages\",\"supportedModels\":[\"shared\",\"messages-only\"]},\n {\"channelKind\":\"responses\",\"supportedModels\":[\"shared\",\"responses-only\"]},\n {\"channelKind\":\"chat\",\"supportedModels\":[\"shared\",\"chat-only\"]}\n ]\n}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/responses/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusCreated {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Channels) != 3 {\n\t\tt.Fatalf(\"channels=%+v, want three detected routes\", response.Channels)\n\t}\n\tfor _, route := range response.Channels {\n\t\tif route.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route account=%q, want %q\", route.AccountUID, response.AccountUID)\n\t\t}\n\t}\n\tif response.Channels[1].ChannelKind != \"responses\" || response.ChannelUID != response.Channels[1].ChannelUID {\n\t\tt.Fatalf(\"请求协议应作为主路由: response=%+v\", response)\n\t}\n\n\tcfg := manager.GetConfig()\n\tif len(cfg.Upstream) != 1 || len(cfg.ResponsesUpstream) != 1 || len(cfg.ChatUpstream) != 1 || len(cfg.GeminiUpstream) != 0 {\n\t\tt.Fatalf(\"协议路由数量错误: messages=%d responses=%d chat=%d gemini=%d\",\n\t\t\tlen(cfg.Upstream), len(cfg.ResponsesUpstream), len(cfg.ChatUpstream), len(cfg.GeminiUpstream))\n\t}\n\tchecks := []struct {\n\t\tchannel config.UpstreamConfig\n\t\tname string\n\t\tmodels []string\n\t}{\n\t\t{cfg.Upstream[0], \"fastaitoken-com-test-claude\", []string{\"shared\", \"messages-only\"}},\n\t\t{cfg.ResponsesUpstream[0], \"fastaitoken-com-test-codex\", []string{\"shared\", \"responses-only\"}},\n\t\t{cfg.ChatUpstream[0], \"fastaitoken-com-test-chat\", []string{\"shared\", \"chat-only\"}},\n\t}\n\tfor _, check := range checks {\n\t\tif check.channel.Name != check.name || check.channel.AccountUID != response.AccountUID {\n\t\t\tt.Fatalf(\"route identity=%+v, want name=%q account=%q\", check.channel, check.name, response.AccountUID)\n\t\t}\n\t\tif strings.Join(check.channel.SupportedModels, \",\") != strings.Join(check.models, \",\") {\n\t\t\tt.Fatalf(\"route %s models=%v, want %v\", check.name, check.channel.SupportedModels, check.models)\n\t\t}\n\t}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesRenamesAndSyncsCredentials(t *testing.T) {\n\tchannels := []config.AccountChannel{\n\t\t{Kind: \"messages\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-messages\", Name: \"old-claude\",\n\t\t\tServiceType: \"claude\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t\t{Kind: \"responses\", Upstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-responses\", Name: \"old-codex\",\n\t\t\tServiceType: \"responses\", AutoManaged: true, BaseURL: \"https://example.com\",\n\t\t\tBaseURLs: []string{\"https://example.com\"}, APIKeys: []string{\"sk-old\"},\n\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: \"sk-old\", BaseURL: \"https://example.com\"}},\n\t\t}},\n\t}\n\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\tName: \"renamed\", APIKeys: []string{\"sk-old\", \"sk-new\"},\n\t}, channels, len(channels))\n\tif err != nil || status != http.StatusOK {\n\t\tt.Fatalf(\"status=%d err=%v\", status, err)\n\t}\n\tif len(updates) != 2 || updates[0].Name != \"renamed-claude\" || updates[1].Name != \"renamed-codex\" {\n\t\tt.Fatalf(\"route names=%+v\", updates)\n\t}\n\tfor _, update := range updates {\n\t\tif len(update.APIKeyConfig) != 2 || update.APIKeyConfig[1].BaseURL != \"https://example.com\" {\n\t\t\tt.Fatalf(\"route credentials=%+v\", update.APIKeyConfig)\n\t\t}\n\t\tif update.APIKeyConfig[1].CredentialUID == \"\" {\n\t\t\tt.Fatalf(\"new credential missing stable uid: %+v\", update.APIKeyConfig[1])\n\t\t}\n\t}\n}\n\n// newCustomManagedChannel 构造一条自定义自动托管渠道,用于地址池编辑链路的表驱动断言。\nfunc newCustomManagedChannel(kind, serviceType string, baseURLs []string, keyConfigs []config.APIKeyConfig) config.AccountChannel {\n\tapiKeys := make([]string, 0, len(keyConfigs))\n\tfor _, keyConfig := range keyConfigs {\n\t\tapiKeys = append(apiKeys, keyConfig.Key)\n\t}\n\tprimary := \"\"\n\tif len(baseURLs) > 0 {\n\t\tprimary = baseURLs[0]\n\t}\n\treturn config.AccountChannel{Kind: kind, Upstream: config.UpstreamConfig{\n\t\tAccountUID: \"acct-custom\", ChannelUID: \"ch-\" + kind, Name: \"custom\" + accountRouteSuffix(kind),\n\t\tServiceType: serviceType, AutoManaged: true, BaseURL: primary,\n\t\tBaseURLs: append([]string(nil), baseURLs...), APIKeys: apiKeys,\n\t\tAPIKeyConfigs: append([]config.APIKeyConfig(nil), keyConfigs...),\n\t}}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesBaseURLPool(t *testing.T) {\n\tconst (\n\t\turlA = \"https://a.example.com\"\n\t\turlB = \"https://b.example.com\"\n\t\turlC = \"https://c.example.com\"\n\t)\n\ttests := []struct {\n\t\tname string\n\t\t// existingURLs / existingKeys 描述渠道现状,requestURLs 为请求里的 baseUrls(nil 表示未提供)\n\t\texistingURLs []string\n\t\texistingKeys []config.APIKeyConfig\n\t\tapiKeys []string\n\t\trequestURLs []string\n\t\twantStatus int\n\t\twantErr bool\n\t\twantBaseURLs []string\n\t\t// wantBindings 与 apiKeys 顺序一致,空串表示该 Key 未绑定端点\n\t\twantBindings []string\n\t}{\n\t\t{\n\t\t\tname: \"单地址扩为两地址\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA, urlB},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlB},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"两地址删除其中一个且保留有效绑定\",\n\t\t\texistingURLs: []string{urlA, urlB},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"地址顺序变化改变首地址\",\n\t\t\texistingURLs: []string{urlA, urlB},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlB, urlA},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlB, urlA},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"尾部斜杠与默认版本前缀等价地址去重\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA, urlA + \"/\", urlA + \"/v1\", \" \" + urlB + \"/ \", urlB},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlB},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"全部非法地址被拒绝\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{\"\", \" \", \"/\"},\n\t\t\twantStatus: http.StatusBadRequest,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"未提供地址时沿用渠道现有地址\",\n\t\t\texistingURLs: []string{urlA, urlB},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlB}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: nil,\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlB},\n\t\t\twantBindings: []string{urlB},\n\t\t},\n\t\t{\n\t\t\tname: \"绑定指向已删除地址时迁移到新首地址\",\n\t\t\texistingURLs: []string{urlA, urlB},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlB}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA, urlC},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlC},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t\t{\n\t\t\tname: \"原本未绑定的凭证保持未绑定\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\"}, {Key: \"sk-2\", BaseURL: urlA}},\n\t\t\tapiKeys: []string{\"sk-1\", \"sk-2\", \"sk-new\"},\n\t\t\trequestURLs: []string{urlB, urlA},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlB, urlA},\n\t\t\t// sk-1 未绑定保持为空;sk-2 绑定仍在池内保持不变;新增 sk-new 绑定首地址\n\t\t\twantBindings: []string{\"\", urlA, urlB},\n\t\t},\n\t\t{\n\t\t\tname: \"非规范绑定被折叠为 canonical 形式\",\n\t\t\texistingURLs: []string{urlA},\n\t\t\texistingKeys: []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA + \"/v1/\"}},\n\t\t\tapiKeys: []string{\"sk-1\"},\n\t\t\trequestURLs: []string{urlA, urlB},\n\t\t\twantStatus: http.StatusOK,\n\t\t\twantBaseURLs: []string{urlA, urlB},\n\t\t\twantBindings: []string{urlA},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tchannels := []config.AccountChannel{\n\t\t\t\tnewCustomManagedChannel(\"messages\", \"claude\", tt.existingURLs, tt.existingKeys),\n\t\t\t}\n\t\t\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\t\t\tName: \"custom\", APIKeys: tt.apiKeys, BaseURLs: tt.requestURLs,\n\t\t\t}, channels, len(channels))\n\t\t\tif status != tt.wantStatus || (err != nil) != tt.wantErr {\n\t\t\t\tt.Fatalf(\"status=%d err=%v, want status=%d err=%v\", status, err, tt.wantStatus, tt.wantErr)\n\t\t\t}\n\t\t\tif tt.wantErr {\n\t\t\t\tif len(updates) != 0 {\n\t\t\t\t\tt.Fatalf(\"非法输入不应产生渠道更新: %+v\", updates)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif len(updates) != 1 {\n\t\t\t\tt.Fatalf(\"updates=%+v, want 1\", updates)\n\t\t\t}\n\t\t\tupdate := updates[0]\n\t\t\tif strings.Join(update.BaseURLs, \",\") != strings.Join(tt.wantBaseURLs, \",\") {\n\t\t\t\tt.Fatalf(\"BaseURLs=%v, want %v\", update.BaseURLs, tt.wantBaseURLs)\n\t\t\t}\n\t\t\tif len(update.APIKeyConfig) != len(tt.wantBindings) {\n\t\t\t\tt.Fatalf(\"APIKeyConfig=%+v, want %d 条\", update.APIKeyConfig, len(tt.wantBindings))\n\t\t\t}\n\t\t\tfor i, keyConfig := range update.APIKeyConfig {\n\t\t\t\tif keyConfig.Key != tt.apiKeys[i] {\n\t\t\t\t\tt.Fatalf(\"APIKeyConfig[%d].Key=%q, want %q\", i, keyConfig.Key, tt.apiKeys[i])\n\t\t\t\t}\n\t\t\t\tif keyConfig.BaseURL != tt.wantBindings[i] {\n\t\t\t\t\tt.Fatalf(\"APIKeyConfig[%d](%s).BaseURL=%q, want %q\", i, keyConfig.Key, keyConfig.BaseURL, tt.wantBindings[i])\n\t\t\t\t}\n\t\t\t\tif keyConfig.CredentialUID == \"\" {\n\t\t\t\t\tt.Fatalf(\"APIKeyConfig[%d] 缺少稳定 credentialUid: %+v\", i, keyConfig)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPlanCustomManagedAccountUpdatesSyncsBaseURLPoolAcrossProtocols(t *testing.T) {\n\tconst (\n\t\turlA = \"https://a.example.com\"\n\t\turlB = \"https://b.example.com\"\n\t)\n\texistingKeys := []config.APIKeyConfig{{Key: \"sk-1\", BaseURL: urlA}}\n\tchannels := []config.AccountChannel{\n\t\tnewCustomManagedChannel(\"messages\", \"claude\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"chat\", \"openai\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"responses\", \"responses\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"gemini\", \"gemini\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"images\", \"openai\", []string{urlA}, existingKeys),\n\t\tnewCustomManagedChannel(\"vectors\", \"openai\", []string{urlA}, existingKeys),\n\t}\n\tupdates, status, err := planCustomManagedAccountUpdates(\"acct-custom\", updateAccountRequest{\n\t\tName: \"custom\", APIKeys: []string{\"sk-1\"}, BaseURLs: []string{urlB + \"/\", urlA},\n\t}, channels, len(channels))\n\tif err != nil || status != http.StatusOK {\n\t\tt.Fatalf(\"status=%d err=%v\", status, err)\n\t}\n\tif len(updates) != len(channels) {\n\t\tt.Fatalf(\"updates=%d, want %d\", len(updates), len(channels))\n\t}\n\twantPool := urlB + \",\" + urlA\n\tfor i, update := range updates {\n\t\tif got := strings.Join(update.BaseURLs, \",\"); got != wantPool {\n\t\t\tt.Fatalf(\"%s 渠道地址池=%q, want %q\", channels[i].Kind, got, wantPool)\n\t\t}\n\t\tif len(update.APIKeyConfig) != 1 || update.APIKeyConfig[0].BaseURL != urlA {\n\t\t\tt.Fatalf(\"%s 渠道凭证绑定=%+v, want %s\", channels[i].Kind, update.APIKeyConfig, urlA)\n\t\t}\n\t}\n}\n\nfunc TestUpdateAccountIgnoresBaseURLsForProviderManagedAccount(t *testing.T) {\n\tconst templateBaseURL = \"https://api.xiaomimimo.com/anthropic\"\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_provider\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_provider\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"` + templateBaseURL + `\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"` + templateBaseURL + `\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\tbody := `{\"name\":\"mimo-main\",\"apiKeys\":[\"sk-existing\"],\"baseUrls\":[\"https://manual-pool.example.com\"]}`\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_provider\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"PUT /api/accounts status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_provider\")\n\tif len(channels) == 0 {\n\t\tt.Fatalf(\"provider 托管账号渠道丢失\")\n\t}\n\tfor _, accountChannel := range channels {\n\t\tchannel := accountChannel.Upstream\n\t\tif channel.BaseURL != templateBaseURL {\n\t\t\tt.Fatalf(\"%s 渠道主地址被手工地址覆盖: %q\", accountChannel.Kind, channel.BaseURL)\n\t\t}\n\t\tfor _, baseURL := range channel.GetAllBaseURLs() {\n\t\t\tif strings.Contains(baseURL, \"manual-pool\") {\n\t\t\t\tt.Fatalf(\"%s 渠道地址池混入手工地址: %v\", accountChannel.Kind, channel.GetAllBaseURLs())\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestProviderRouteNameAndPrimaryResult(t *testing.T) {\n\tbase := \"mimo-test\"\n\ttests := []struct {\n\t\troute config.ProviderRoute\n\t\twant string\n\t}{\n\t\t{route: config.ProviderRoute{ChannelKind: \"messages\"}, want: \"mimo-test-claude\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"chat\"}, want: \"mimo-test-chat\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"responses\"}, want: \"mimo-test-codex\"},\n\t\t{route: config.ProviderRoute{ChannelKind: \"gemini\"}, want: \"mimo-test-gemini\"},\n\t}\n\tfor _, tt := range tests {\n\t\tif got := providerRouteName(base, tt.route, true); got != tt.want {\n\t\t\tt.Fatalf(\"providerRouteName(%s)=%q, want %q\", tt.route.ChannelKind, got, tt.want)\n\t\t}\n\t}\n\tif got := providerRouteName(base, config.ProviderRoute{ChannelKind: \"messages\"}, false); got != base {\n\t\tt.Fatalf(\"single route name=%q, want %q\", got, base)\n\t}\n\n\tresults := []AutoAddChannelResult{\n\t\t{ChannelKind: \"messages\", ChannelUID: \"ch_messages\", Index: 1},\n\t\t{ChannelKind: \"chat\", ChannelUID: \"ch_chat\", Index: 2},\n\t\t{ChannelKind: \"responses\", ChannelUID: \"ch_responses\", Index: 3},\n\t}\n\tprimary := primaryAutoAddResult(results, \"chat\")\n\tif primary.ChannelUID != \"ch_chat\" || primary.Index != 2 {\n\t\tt.Fatalf(\"primary=%+v, want chat result\", primary)\n\t}\n}\n\nfunc TestManagedAccountUIDForProviderUsesExistingAccount(t *testing.T) {\n\tcfg := config.Config{ManagedAccounts: []config.ManagedAccountConfig{\n\t\t{AccountUID: \"acct-old\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-new\", ProviderID: \"mimo\"},\n\t\t{AccountUID: \"acct-deepseek\", ProviderID: \"deepseek\"},\n\t}}\n\tif got := managedAccountUIDForProvider(cfg, \"mimo\"); got != \"acct-new\" {\n\t\tt.Fatalf(\"mimo existing account = %q, want acct-new\", got)\n\t}\n\tif got := managedAccountUIDForProvider(cfg, \"unknown\"); got != \"\" {\n\t\tt.Fatalf(\"unknown provider 不应命中账号: %q\", got)\n\t}\n}\n\nfunc TestListManagedAccountsDoesNotExposeVolcengineSecret(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-secret\",\"volcengineAccessKey\":{\"accessKeyId\":\"AKIDEXAMPLE\",\"secretAccessKey\":\"SECRET_MUST_NOT_LEAK\",\"plan\":\"agent_plan\",\"planTier\":\"Large\",\"planStatus\":\"Running\"}}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager})\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tbody := w.Body.String()\n\tif strings.Contains(body, \"SECRET_MUST_NOT_LEAK\") || strings.Contains(body, \"ark-secret\") {\n\t\tt.Fatalf(\"管理 API 泄漏凭证: %s\", body)\n\t}\n\tfor _, expected := range []string{`\"hasVolcengineAccessKey\":true`, `\"volcenginePlan\":\"agent_plan\"`, `\"volcenginePlanTier\":\"Large\"`} {\n\t\tif !strings.Contains(body, expected) {\n\t\t\tt.Fatalf(\"响应缺少 %s: %s\", expected, body)\n\t\t}\n\t}\n}\n\nfunc TestKimiConsoleTokenLifecycleDoesNotExposeToken(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_kimi\",\"providerId\":\"kimi\",\"name\":\"kimi\",\"credentials\":[{\"credentialUid\":\"cred_kimi\",\"apiKey\":\"sk-kimi-secret\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_kimi\",\"channelUid\":\"ch_kimi\",\"providerId\":\"kimi\",\"name\":\"kimi\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.kimi.com/coding\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_kimi\",\"baseUrl\":\"https://api.kimi.com/coding\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\n\tkimiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Authorization\") != \"Bearer web-session-secret\" {\n\t\t\tt.Fatalf(\"Kimi 请求未使用保存的会话令牌\")\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase kimiUsagesPath:\n\t\t\t_, _ = w.Write([]byte(`{\"usages\":[{\"scope\":\"FEATURE_CODING\",\"detail\":{\"limit\":\"100\",\"remaining\":\"80\",\"resetTime\":\"2026-07-27T16:13:43Z\"},\"limits\":[{\"window\":{\"duration\":300,\"timeUnit\":\"TIME_UNIT_MINUTE\"},\"detail\":{\"limit\":\"100\",\"remaining\":\"90\",\"resetTime\":\"2026-07-21T02:13:43Z\"}}]}],\"totalQuota\":{\"limit\":\"100\",\"remaining\":\"80\"}}`))\n\t\tcase kimiSubscriptionStatsPath:\n\t\t\t_, _ = w.Write([]byte(`{\"ratelimitCode5h\":{\"ratio\":0.1,\"enabled\":true},\"ratelimitCode7d\":{\"ratio\":0.2,\"enabled\":true},\"subscriptionBalance\":{\"feature\":\"FEATURE_OMNI\",\"type\":\"SUBSCRIPTION\",\"unit\":\"UNIT_CREDIT\",\"amountUsedRatio\":0.2,\"kimiCodeUsedRatio\":0.2,\"expireTime\":\"2026-08-20T16:14:07Z\"}}`))\n\t\tdefault:\n\t\t\thttp.NotFound(w, r)\n\t\t}\n\t}))\n\tdefer kimiServer.Close()\n\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: cfgManager,\n\t\tKimiConsoleClient: &KimiConsoleClient{\n\t\t\tHTTPClient: kimiServer.Client(), BaseURL: kimiServer.URL,\n\t\t},\n\t})\n\tput := httptest.NewRequest(\n\t\thttp.MethodPut,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token\",\n\t\tbytes.NewBufferString(`{\"accessToken\":\"Bearer web-session-secret\"}`),\n\t)\n\tput.Header.Set(\"Content-Type\", \"application/json\")\n\tputRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(putRecorder, put)\n\tif putRecorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"绑定 Kimi 令牌失败: status=%d body=%s\", putRecorder.Code, putRecorder.Body.String())\n\t}\n\tif strings.Contains(putRecorder.Body.String(), \"web-session-secret\") || strings.Contains(putRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"绑定响应泄露 Kimi 令牌: %s\", putRecorder.Body.String())\n\t}\n\tcredential, ok := cfgManager.GetManagedAccountCredential(\"acct_kimi\", \"cred_kimi\")\n\tif !ok || credential.KimiConsole == nil || credential.KimiConsole.AccessToken != \"web-session-secret\" {\n\t\tt.Fatalf(\"Kimi 令牌未持久化: %+v\", credential)\n\t}\n\tpersisted, err := os.ReadFile(configPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !strings.Contains(string(persisted), `\"accessToken\": \"web-session-secret\"`) {\n\t\tt.Fatalf(\"配置文件未保存 Kimi 令牌\")\n\t}\n\n\tlistRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(listRecorder, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif listRecorder.Code != http.StatusOK || strings.Contains(listRecorder.Body.String(), \"web-session-secret\") || strings.Contains(listRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"账号列表响应异常或泄露令牌: status=%d body=%s\", listRecorder.Code, listRecorder.Body.String())\n\t}\n\tif !strings.Contains(listRecorder.Body.String(), `\"hasKimiConsoleToken\":true`) || !strings.Contains(listRecorder.Body.String(), `\"kimiCodeUsage\"`) {\n\t\tt.Fatalf(\"账号列表缺少 Kimi 用量快照: %s\", listRecorder.Body.String())\n\t}\n\n\trefreshRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(refreshRecorder, httptest.NewRequest(\n\t\thttp.MethodPost,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token/refresh\",\n\t\tnil,\n\t))\n\tif refreshRecorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"刷新 Kimi 余量失败: status=%d body=%s\", refreshRecorder.Code, refreshRecorder.Body.String())\n\t}\n\tif strings.Contains(refreshRecorder.Body.String(), \"web-session-secret\") || strings.Contains(refreshRecorder.Body.String(), \"accessToken\") {\n\t\tt.Fatalf(\"刷新响应泄露 Kimi 令牌: %s\", refreshRecorder.Body.String())\n\t}\n\n\tdeleteRecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(deleteRecorder, httptest.NewRequest(\n\t\thttp.MethodDelete,\n\t\t\"/api/accounts/acct_kimi/credentials/cred_kimi/kimi-console-token\",\n\t\tnil,\n\t))\n\tif deleteRecorder.Code != http.StatusNoContent {\n\t\tt.Fatalf(\"解绑 Kimi 令牌失败: status=%d body=%s\", deleteRecorder.Code, deleteRecorder.Body.String())\n\t}\n\tcredential, _ = cfgManager.GetManagedAccountCredential(\"acct_kimi\", \"cred_kimi\")\n\tif credential.KimiConsole != nil {\n\t\tt.Fatalf(\"Kimi 令牌解绑后仍存在: %+v\", credential.KimiConsole)\n\t}\n\tpersisted, err = os.ReadFile(configPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(persisted), \"web-session-secret\") {\n\t\tt.Fatalf(\"解绑后配置文件仍包含 Kimi 令牌\")\n\t}\n}\n\nfunc TestSetVolcengineAccessKeyDetectsPlanBeforePersisting(t *testing.T) {\n\tcontrolPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Query().Get(\"Action\") != \"GetPersonalPlan\" {\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\tvar body struct{ Plan string }\n\t\t_ = json.NewDecoder(r.Body).Decode(&body)\n\t\tif body.Plan == \"CodingPlan\" {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t_, _ = w.Write([]byte(`{\"ResponseMetadata\":{\"Error\":{\"Code\":\"ResourceNotFound.Plan\",\"Message\":\"not found\"}}}`))\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\"Result\":{\"PlanType\":\"Large\",\"Status\":\"Running\"}}`))\n\t}))\n\tdefer controlPlane.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"credentials\":[{\"credentialUid\":\"cred_volc\",\"apiKey\":\"ark-inference\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_volc\",\"channelUid\":\"ch_volc\",\"providerId\":\"volcengine\",\"name\":\"volc\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_volc\",\"baseUrl\":\"https://ark.cn-beijing.volces.com/api/plan\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trunner := NewAutoDiscoveryRunner(nil, nil)\n\trunner.client = controlPlane.Client()\n\trunner.volcengineControlPlaneEndpoint = controlPlane.URL\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, Runner: runner})\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_volc/credentials/cred_volc/volcengine-access-key\", bytes.NewBufferString(`{\"accessKeyId\":\"AKID\",\"secretAccessKey\":\"SECRET\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"plan\":\"agent_plan\"`) {\n\t\tt.Fatalf(\"status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_volc\", \"cred_volc\")\n\tif !ok || credential.VolcengineAccessKey == nil || credential.VolcengineAccessKey.SecretAccessKey != \"SECRET\" || credential.VolcengineAccessKey.Plan != \"agent_plan\" {\n\t\tt.Fatalf(\"凭证未正确持久化: %+v\", credential)\n\t}\n}\n\nfunc TestSetMiMoConsoleCookieRequiresConfirmationBeforeAdoptingKey(t *testing.T) {\n\tconst oldKey = \"tp-old-1234567890123456789012345678901234567890\"\n\tconst cookieKey = \"tp-new-1234567890123456789012345678901234567890\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != \"api-platform_serviceToken=session; userId=42\" {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tswitch r.URL.Path {\n\t\tcase \"/api/v1/userProfile\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"userId\":\"42\"}}`))\n\t\tcase \"/api/v1/tokenPlan/detail\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"planCode\":\"max\",\"planName\":\"Max\",\"currentPeriodEnd\":\"2026-07-29 23:59:59\"}}`))\n\t\tcase \"/api/v1/tokenPlan/usage\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":{\"monthUsage\":{\"percent\":0.25,\"items\":[{\"used\":25,\"limit\":100}]},\"usage\":{\"percent\":0.5,\"items\":[{\"used\":50,\"limit\":100}]}}}`))\n\t\tcase \"/api/v1/tokenPlan/apiKey/raw\":\n\t\t\t_, _ = w.Write([]byte(`{\"code\":0,\"data\":\"` + cookieKey + `\"}`))\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t}\n\t}))\n\tdefer console.Close()\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"credentials\":[{\"credentialUid\":\"cred_mimo\",\"apiKey\":\"` + oldKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_mimo\",\"channelUid\":\"ch_mimo\",\"providerId\":\"mimo\",\"name\":\"mimo\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_mimo\",\"baseUrl\":\"https://token-plan-cn.xiaomimimo.com/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\tdeps := &AutoManagedDeps{CfgManager: manager, MiMoConsoleClient: &MiMoConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL}}\n\trouter := setupAutoManagedRouter(deps)\n\tbody := `{\"cookie\":\"api-platform_serviceToken=session; userId=42\"}`\n\trequest := func(payload string) *httptest.ResponseRecorder {\n\t\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_mimo/credentials/cred_mimo/mimo-console-cookie\", bytes.NewBufferString(payload))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tw := httptest.NewRecorder()\n\t\trouter.ServeHTTP(w, req)\n\t\treturn w\n\t}\n\tw := request(body)\n\tif w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), `\"code\":\"mimo_cookie_key_mismatch\"`) {\n\t\tt.Fatalf(\"未要求确认: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ := manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != oldKey || credential.MiMoConsole != nil {\n\t\tt.Fatalf(\"确认前不应修改配置: %+v\", credential)\n\t}\n\tw = request(`{\"cookie\":\"api-platform_serviceToken=session; userId=42\",\"adoptCookieKey\":true}`)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"keyAdopted\":true`) {\n\t\tt.Fatalf(\"确认采用失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, _ = manager.GetManagedAccountCredential(\"acct_mimo\", \"cred_mimo\")\n\tif credential.APIKey != cookieKey || credential.MiMoConsole == nil {\n\t\tt.Fatalf(\"确认后未采用 Cookie Key: %+v\", credential)\n\t}\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tif strings.Contains(list.Body.String(), \"api-platform_serviceToken\") || strings.Contains(list.Body.String(), cookieKey) {\n\t\tt.Fatalf(\"账号列表泄漏 Cookie 或原始 Key: %s\", list.Body.String())\n\t}\n}\n\nfunc TestCompshareConsoleCookieBindingDoesNotExposeSecrets(t *testing.T) {\n\tconst currentKey = \"sk-cp-test-current\"\n\tconsole := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Header.Get(\"Cookie\") != testCompshareCookie {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\t_, _ = w.Write([]byte(`{\n \"RetCode\": 0,\n \"UserPlans\": [{\n \"Code\": \"cppkg-test\", \"PlanCode\": \"cp-plan-test\", \"PlanName\": \"Pro\", \"DisplayName\": \"Pro 增强版\",\n \"LimitPer5h\": 3000, \"LimitPerWeek\": 7500, \"LimitPerMonth\": 19000,\n \"UsagePer5h\": 0, \"UsagePerWeek\": 100, \"UsagePerMonth\": 6496,\n \"ConcurrencyLimit\": 10, \"Status\": 1, \"ExpireAt\": 1785037981,\n \"Keys\": [{\"APIKey\": \"` + currentKey + `\"}]\n }]\n}`))\n\t}))\n\tdefer console.Close()\n\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_compshare\",\"providerId\":\"compshare\",\"name\":\"compshare\",\"credentials\":[{\"credentialUid\":\"cred_compshare\",\"apiKey\":\"` + currentKey + `\"}]}],\n \"upstream\":[{\"accountUid\":\"acct_compshare\",\"channelUid\":\"ch_compshare\",\"providerId\":\"compshare\",\"name\":\"compshare\",\"serviceType\":\"claude\",\"autoManaged\":true,\"baseUrl\":\"https://api.compshare.cn/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_compshare\",\"baseUrl\":\"https://api.compshare.cn/anthropic\"}]}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer errutil.IgnoreDeferred(manager.Close)\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{\n\t\tCfgManager: manager,\n\t\tCompshareConsoleClient: &CompshareConsoleClient{HTTPClient: console.Client(), BaseURL: console.URL},\n\t})\n\n\treq := httptest.NewRequest(http.MethodPut, \"/api/accounts/acct_compshare/credentials/cred_compshare/compshare-console-cookie\", bytes.NewBufferString(`{\"cookie\":\"`+testCompshareCookie+`\"}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `\"displayName\":\"Pro 增强版\"`) {\n\t\tt.Fatalf(\"绑定失败: status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tcredential, ok := manager.GetManagedAccountCredential(\"acct_compshare\", \"cred_compshare\")\n\tif !ok || credential.CompshareConsole == nil || credential.CompshareConsole.Cookie != testCompshareCookie {\n\t\tt.Fatalf(\"控制台套餐未持久化: %+v\", credential)\n\t}\n\n\tlist := httptest.NewRecorder()\n\trouter.ServeHTTP(list, httptest.NewRequest(http.MethodGet, \"/api/accounts\", nil))\n\tresponse := list.Body.String()\n\tfor _, secret := range []string{\"U_JWT_TOKEN\", \"test-session\", \"test_user@console.compshare.cn\", \"org-test\", currentKey, \"cppkg-test\"} {\n\t\tif strings.Contains(response, secret) {\n\t\t\tt.Fatalf(\"账号列表泄漏敏感字段 %q: %s\", secret, response)\n\t\t}\n\t}\n\tfor _, expected := range []string{`\"hasCompshareConsoleCookie\":true`, `\"planCode\":\"cp-plan-test\"`, `\"monthlyUsage\":{\"used\":6496,\"limit\":19000`} {\n\t\tif !strings.Contains(response, expected) {\n\t\t\tt.Fatalf(\"账号列表缺少套餐字段 %s: %s\", expected, response)\n\t\t}\n\t}\n}\n\nfunc TestProviderAutoAddReusesExistingAccount(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\": [{\"accountUid\":\"acct_test\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"credentials\":[{\"credentialUid\":\"cred_test\",\"apiKey\":\"sk-existing\"}]}],\n \"upstream\": [{\"accountUid\":\"acct_test\",\"channelUid\":\"ch_messages\",\"providerId\":\"mimo\",\"name\":\"mimo-main\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\",\"apiKeyConfigs\":[{\"credentialUid\":\"cred_test\",\"baseUrl\":\"https://api.xiaomimimo.com/anthropic\"}],\"autoManaged\":true,\"status\":\"active\"}],\n \"responsesUpstream\": [], \"geminiUpstream\": [], \"chatUpstream\": [], \"imagesUpstream\": [], \"vectorsUpstream\": []\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcfgManager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = cfgManager.Close() })\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: cfgManager})\n\treq := httptest.NewRequest(http.MethodPost, \"/api/messages/channels/auto-add\", bytes.NewBufferString(`{\"providerId\":\"mimo\",\"apiKeys\":[\"sk-existing\"]}`))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\trouter.ServeHTTP(w, req)\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"追加已有 provider key status=%d body=%s\", w.Code, w.Body.String())\n\t}\n\tchannels := cfgManager.GetAccountChannels(\"acct_test\")\n\tif len(channels) != 4 {\n\t\tt.Fatalf(\"旧账号应自动补齐模板中的协议渠道: %+v\", channels)\n\t}\n\tkinds := make(map[string]bool, len(channels))\n\tfor _, channel := range channels {\n\t\tkinds[channel.Kind] = true\n\t}\n\tfor _, kind := range []string{\"messages\", \"chat\", \"responses\", \"gemini\"} {\n\t\tif !kinds[kind] {\n\t\t\tt.Fatalf(\"旧账号缺少 %s route: %+v\", kind, channels)\n\t\t}\n\t}\n\tvar response AutoAddResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.AccountUID != \"acct_test\" {\n\t\tt.Fatalf(\"响应未返回已有账号: body=%s err=%v\", w.Body.String(), err)\n\t}\n\tif len(response.Channels) != 4 {\n\t\tt.Fatalf(\"响应未返回补齐后的全部渠道: %+v\", response.Channels)\n\t}\n}\n\nfunc TestMissingProviderAccountRoutes(t *testing.T) {\n\ttmpl, ok := config.GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"缺少 deepseek provider 模板\")\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{ServiceType: \"claude\"},\n\t}}\n\tmissing := missingProviderAccountRoutes(tmpl, existing)\n\tif len(missing) != 2 || missing[0].ChannelKind != \"chat\" || missing[1].ChannelKind != \"responses\" {\n\t\tt.Fatalf(\"missing routes = %+v\", missing)\n\t}\n\tconfigs, baseURLs, err := bindProviderRouteKeys(tmpl, missing[0], []string{\"sk-existing\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(configs) != 1 || configs[0].BaseURL != \"https://api.deepseek.com\" || len(baseURLs) != 1 {\n\t\tt.Fatalf(\"route 绑定结果不正确: configs=%+v baseURLs=%v\", configs, baseURLs)\n\t}\n}\n\nfunc TestBindProviderRouteKeysPreservesExistingEndpointAffinity(t *testing.T) {\n\ttests := []struct {\n\t\tproviderID string\n\t\tapiKey string\n\t\texistingBaseURL string\n\t\ttargetKind string\n\t\twantBaseURL string\n\t}{\n\t\t{\n\t\t\tproviderID: \"mimo\", apiKey: \"tp-existing\",\n\t\t\texistingBaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\t\ttargetKind: \"chat\", wantBaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\",\n\t\t},\n\t\t{\n\t\t\tproviderID: \"volcengine\", apiKey: \"ark-existing\",\n\t\t\texistingBaseURL: \"https://ark.cn-beijing.volces.com/api/coding\",\n\t\t\ttargetKind: \"responses\", wantBaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.providerID, func(t *testing.T) {\n\t\t\ttmpl, ok := config.GetProviderTemplate(tt.providerID)\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"缺少 provider 模板: %s\", tt.providerID)\n\t\t\t}\n\t\t\texisting := []config.AccountChannel{{\n\t\t\t\tKind: \"messages\",\n\t\t\t\tUpstream: config.UpstreamConfig{\n\t\t\t\t\tProviderID: tt.providerID, ServiceType: \"claude\", APIKeys: []string{tt.apiKey},\n\t\t\t\t\tAPIKeyConfigs: []config.APIKeyConfig{{Key: tt.apiKey, BaseURL: tt.existingBaseURL}},\n\t\t\t\t},\n\t\t\t}}\n\t\t\tvar target config.ProviderRoute\n\t\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\t\tif route.ChannelKind == tt.targetKind {\n\t\t\t\t\ttarget = route\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tconfigs, _, err := bindProviderRouteKeysWithAffinities(\n\t\t\t\ttmpl, target, []string{tt.apiKey}, providerKeyCandidateAffinities(tmpl, existing),\n\t\t\t)\n\t\t\tif err != nil || len(configs) != 1 || configs[0].BaseURL != tt.wantBaseURL {\n\t\t\t\tt.Fatalf(\"亲和绑定错误: configs=%+v err=%v want=%s\", configs, err, tt.wantBaseURL)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPlanProviderAccountRouteAdditionsRejectsNewKeyBeforeMutation(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusUnauthorized)\n\t}))\n\tdefer server.Close()\n\n\ttmpl := &config.ProviderTemplate{\n\t\tProviderID: \"test-provider\",\n\t\tRoutes: []config.ProviderRoute{\n\t\t\t{ChannelKind: \"messages\", ServiceType: \"claude\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/anthropic\"}}},\n\t\t\t{ChannelKind: \"chat\", ServiceType: \"openai\", Candidates: []config.ProviderCandidate{{BaseURL: server.URL + \"/v1\"}}},\n\t\t},\n\t}\n\texisting := []config.AccountChannel{{\n\t\tKind: \"messages\",\n\t\tUpstream: config.UpstreamConfig{\n\t\t\tAccountUID: \"acct_test\", ChannelUID: \"ch_messages\", Name: \"test-provider\",\n\t\t\tProviderID: \"test-provider\", ServiceType: \"claude\", AutoManaged: true, APIKeys: []string{\"sk-existing\"},\n\t\t},\n\t}}\n\tadditions, status, err := planProviderAccountRouteAdditions(\n\t\tt.Context(), config.Config{}, tmpl, \"acct_test\", []string{\"sk-existing\", \"sk-invalid\"}, existing,\n\t)\n\tif err == nil || status != http.StatusBadRequest || len(additions) != 0 {\n\t\tt.Fatalf(\"无效新 Key 应在生成新增 route 前失败: status=%d additions=%+v err=%v\", status, additions, err)\n\t}\n}\n\nfunc TestAutoAddHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\tbody := `{\"baseUrls\":[\"https://api.example.com\"],\"apiKeys\":[\"sk-test\"]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/auto-add\", bytes.NewBufferString(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoDiscoverHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/unknown/channels/0/auto-discover\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n\nfunc TestAutoStatusHandler_InvalidKind(t *testing.T) {\n\tdeps := &AutoManagedDeps{\n\t\tCfgManager: nil,\n\t\tRunner: nil,\n\t}\n\tr := setupAutoManagedRouter(deps)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/api/unknown/channels/0/auto-status\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\t// 静态路由注册后,无效 kind 直接返回 404(路由不存在)\n\tif w.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"期望 404, 实际=%d\", w.Code)\n\t}\n}\n"}}, "backend-go/internal/autopilot/smart_router_model_capabilities_test.go": {"48488333f5455eef9b0999cee1eaf5bb63dd23d6": {"t": 1785746065.5722396, "n": 9970, "h": "fb78b88d30413d2a", "c": "package autopilot\n\nimport (\n\t\"math\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/BenedictKing/ccx/internal/errutil\"\n\t\"github.com/BenedictKing/ccx/internal/scheduler\"\n)\n\nfunc TestBuildChannelEntryUsesRegistryCapabilities(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tmodel string\n\t\tupstream config.UpstreamConfig\n\t\twantVision bool\n\t\twantTools bool\n\t\twantReasoning bool\n\t}{\n\t\t{\n\t\t\tname: \"多模态模型使用内置能力\", model: \"mimo-v2.5\",\n\t\t\twantVision: true, wantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"文本模型不会误报视觉\", model: \"mimo-v2.5-pro\",\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"NoVision 强制覆盖注册表\", model: \"mimo-v2.5\",\n\t\t\tupstream: config.UpstreamConfig{NoVision: true},\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"映射后的 NoVisionModels 强制覆盖注册表\", model: \"alias-model\",\n\t\t\tupstream: config.UpstreamConfig{\n\t\t\t\tModelMapping: map[string]string{\"alias-model\": \"mimo-v2.5\"},\n\t\t\t\tNoVisionModels: []string{\"mimo-v2.5\"},\n\t\t\t},\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"渠道能力覆盖可提供正向能力\", model: \"custom-model\",\n\t\t\tupstream: config.UpstreamConfig{ModelCapabilities: map[string]config.UpstreamModelCapability{\n\t\t\t\t\"custom-model\": {\n\t\t\t\t\tThinkingMode: \"thinking\",\n\t\t\t\t\tCapabilities: map[string]bool{\"vision\": true, \"toolCalls\": true},\n\t\t\t\t},\n\t\t\t}},\n\t\t\twantVision: true, wantTools: true, wantReasoning: true,\n\t\t},\n\t}\n\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tupstream := tt.upstream\n\t\t\tupstream.ChannelUID = \"ch_registry\"\n\t\t\tentry := router.buildChannelEntry(\n\t\t\t\tscheduler.ChannelInfo{Index: 0, Name: \"registry\", Status: \"active\"},\n\t\t\t\t&upstream, \"messages\", tt.model, nil,\n\t\t\t)\n\t\t\tif entry.SupportsVision != tt.wantVision || entry.SupportsToolCalls != tt.wantTools ||\n\t\t\t\tentry.SupportsReasoning != tt.wantReasoning {\n\t\t\t\tt.Fatalf(\"capabilities vision=%v tools=%v reasoning=%v, want %v/%v/%v\",\n\t\t\t\t\tentry.SupportsVision, entry.SupportsToolCalls, entry.SupportsReasoning,\n\t\t\t\t\ttt.wantVision, tt.wantTools, tt.wantReasoning)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBuildChannelEntryUsesMappedModelQualityTier(t *testing.T) {\n\tmodelStore := newModelPreviewStore(t,\n\t\tModelProfile{\n\t\t\tChannelUID: \"ch_kimi\", ChannelKind: \"messages\", MetricsKey: \"k3-endpoint\",\n\t\t\tModelID: \"k3\", ModelFamily: ModelFamilyUnknown, QualityTier: QualityTierLow,\n\t\t\tSource: \"auto_discovery\",\n\t\t\tProbeSuccess: true,\n\t\t},\n\t\tModelProfile{\n\t\t\tChannelUID: \"ch_kimi\", ChannelKind: \"messages\", MetricsKey: \"coding-endpoint\",\n\t\t\tModelID: \"kimi-for-coding\", ModelFamily: ModelFamilyKimi, QualityTier: QualityTierNormal,\n\t\t\tSource: \"auto_discovery\",\n\t\t\tProbeSuccess: true,\n\t\t},\n\t)\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\trouter.SetModelProfileStore(modelStore)\n\tupstream := &config.UpstreamConfig{ChannelUID: \"ch_kimi\"}\n\tchannel := scheduler.ChannelInfo{Index: 0, Name: \"kimi\", Status: \"active\"}\n\n\tk3Entry := router.buildChannelEntry(channel, upstream, \"messages\", \"k3\", nil)\n\tcodingEntry := router.buildChannelEntry(channel, upstream, \"messages\", \"kimi-for-coding\", nil)\n\tif k3Entry.ScoringCandidate.QualityTier != QualityTierPremium {\n\t\tt.Fatalf(\"K3 quality tier = %q, want premium\", k3Entry.ScoringCandidate.QualityTier)\n\t}\n\tif codingEntry.ScoringCandidate.QualityTier != QualityTierHigh {\n\t\tt.Fatalf(\"kimi-for-coding quality tier = %q, want high\", codingEntry.ScoringCandidate.QualityTier)\n\t}\n}\n\nfunc TestBuildChannelEntryAppliesProviderTimePricingAfterActivation(t *testing.T) {\n\tmanager, cleanup := createTestConfigManager(t, config.Config{AutopilotRouting: config.DefaultAutopilotRoutingConfig()})\n\tdefer cleanup()\n\trouter := NewSmartRouter(nil, nil, nil, manager)\n\tupstream := &config.UpstreamConfig{ChannelUID: \"ch_deepseek\", ProviderID: \"deepseek\"}\n\tchannel := scheduler.ChannelInfo{Index: 0, Name: \"deepseek\", Status: \"active\"}\n\n\trouter.now = func() time.Time {\n\t\treturn time.Date(2026, 7, 19, 10, 0, 0, 0, time.FixedZone(\"CST\", 8*60*60))\n\t}\n\tbefore := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\trouter.now = func() time.Time {\n\t\treturn time.Date(2026, 7, 20, 10, 0, 0, 0, time.FixedZone(\"CST\", 8*60*60))\n\t}\n\tpeak := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\tif before.EstimatedCost <= 0 || math.Abs(peak.EstimatedCost-before.EstimatedCost*2) > 1e-9 {\n\t\tt.Fatalf(\"estimated cost before=%v peak=%v\", before.EstimatedCost, peak.EstimatedCost)\n\t}\n\n\tupstream.ProviderID = \"\"\n\tupstream.BaseURL = \"https://api.deepseek.com/anthropic\"\n\tmanualOfficial := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\tif math.Abs(manualOfficial.EstimatedCost-peak.EstimatedCost) > 1e-9 {\n\t\tt.Fatalf(\"手动官方 DeepSeek 渠道未应用峰值倍率: managed=%v manual=%v\", peak.EstimatedCost, manualOfficial.EstimatedCost)\n\t}\n\tupstream.BaseURL = \"https://relay.example/v1\"\n\trelay := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\tif math.Abs(relay.EstimatedCost-before.EstimatedCost) > 1e-9 {\n\t\tt.Fatalf(\"第三方 relay 不应应用 DeepSeek 官方倍率: before=%v relay=%v\", before.EstimatedCost, relay.EstimatedCost)\n\t}\n}\n\nfunc TestSmartRouterAppliesCanonicalBenchmarkToDomainScore(t *testing.T) {\n\t// 期望值由当前 registry 推导,避免评测数据刷新导致断言失效。\n\twant := canonicalDomainCeiling(t, \"gpt-5.6-sol\", TaskDomainReasoning)\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\tentry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"sol\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_sol\"}, \"responses\", \"gpt-5.6-sol\", nil,\n\t)\n\tapplyDomainStrength(&entry, TaskDomainReasoning)\n\n\tif math.Abs(entry.ScoringCandidate.DomainStrengthScore-want) > 1e-9 {\n\t\tt.Fatalf(\"DomainStrengthScore = %v, want %v\", entry.ScoringCandidate.DomainStrengthScore, want)\n\t}\n\tif entry.ScoringCandidate.DomainEvidence == nil ||\n\t\tentry.ScoringCandidate.DomainEvidence.Source != \"canonical_benchmark\" ||\n\t\tentry.ScoringCandidate.DomainEvidence.CanonicalModel != \"gpt-5.6-sol\" {\n\t\tt.Fatalf(\"DomainEvidence = %+v\", entry.ScoringCandidate.DomainEvidence)\n\t}\n\n\tscored := ScoreCandidate(entry.ScoringCandidate, ScoringContext{Weights: DefaultTaskWeights()[TaskClassWorker]})\n\tif scored.DomainEvidence == nil || math.Abs(scored.DomainEvidence.CanonicalCeiling-want) > 1e-9 {\n\t\tt.Fatalf(\"scored DomainEvidence = %+v\", scored.DomainEvidence)\n\t}\n}\n\nfunc TestSmartRouterPrefersEndpointDomainOverrideAndAppliesProviderFactor(t *testing.T) {\n\tprofile := &ModelProfile{\n\t\tChannelUID: \"ch_sol\",\n\t\tChannelKind: \"responses\",\n\t\tMetricsKey: \"endpoint-a\",\n\t\tModelID: \"gpt-5.6-sol\",\n\t\tModelFamily: ModelFamilyOpenAI,\n\t\tProviderQualityScore: 0.8,\n\t\tProviderQualityConfidence: 0.75,\n\t}\n\tstore := &ModelProfileStore{cache: map[string]*ModelProfile{\"sol\": profile}}\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\trouter.SetModelProfileStore(store)\n\n\tentry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"sol\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_sol\"}, \"responses\", \"gpt-5.6-sol\", nil,\n\t)\n\tapplyDomainStrength(&entry, TaskDomainReasoning)\n\t// factor = 1 - 0.75 * (1 - 0.8) = 0.85;上界随 registry 刷新动态推导。\n\tif want := canonicalDomainCeiling(t, \"gpt-5.6-sol\", TaskDomainReasoning) * 0.85; math.Abs(entry.ScoringCandidate.DomainStrengthScore-want) > 1e-9 {\n\t\tt.Fatalf(\"quality-adjusted DomainStrengthScore = %v, want %v\", entry.ScoringCandidate.DomainStrengthScore, want)\n\t}\n\n\tprofile.TaskDomainStrengths = map[TaskDomain]float64{TaskDomainReasoning: 0.97}\n\tentry = router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"sol\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_sol\"}, \"responses\", \"gpt-5.6-sol\", nil,\n\t)\n\tapplyDomainStrength(&entry, TaskDomainReasoning)\n\tif entry.ScoringCandidate.DomainStrengthScore != 0.97 ||\n\t\tentry.ScoringCandidate.DomainEvidence.Source != \"endpoint_override\" {\n\t\tt.Fatalf(\"endpoint override evidence = %+v\", entry.ScoringCandidate.DomainEvidence)\n\t}\n}\n\nfunc TestBuildChannelEntryMergesRegistryAndEndpointCapabilities(t *testing.T) {\n\tstore, err := NewProfileStore(filepath.Join(t.TempDir(), \"profiles.db\"))\n\tif err != nil {\n\t\tt.Fatalf(\"创建 ProfileStore 失败: %v\", err)\n\t}\n\tdefer errutil.IgnoreDeferred(store.Close)\n\n\tprofiles := []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_registry\", ChannelUID: \"ch_registry\", ChannelKind: \"messages\",\n\t\t\tHealthState: HealthStateUnknown, QualityTier: QualityTierNormal,\n\t\t\tStabilityTier: StabilityTierNormal, SpeedTier: SpeedTierNormal, CostTier: CostTierNormal,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_profile\", ChannelUID: \"ch_profile\", ChannelKind: \"messages\",\n\t\t\tHealthState: HealthStateUnknown, QualityTier: QualityTierNormal,\n\t\t\tStabilityTier: StabilityTierNormal, SpeedTier: SpeedTierNormal, CostTier: CostTierNormal,\n\t\t\tSupportsVision: true, SupportsToolCalls: true, SupportsReasoning: true,\n\t\t},\n\t}\n\tfor _, profile := range profiles {\n\t\tif err := store.Upsert(profile); err != nil {\n\t\t\tt.Fatalf(\"写入 profile 失败: %v\", err)\n\t\t}\n\t}\n\n\trouter := NewSmartRouter(store, nil, nil, nil)\n\tregistryEntry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"registry\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_registry\"}, \"messages\", \"glm-5.2\", nil,\n\t)\n\tif !registryEntry.SupportsToolCalls || !registryEntry.SupportsReasoning {\n\t\tt.Fatalf(\"空画像不应抹掉注册表能力: %+v\", registryEntry)\n\t}\n\tif reasons := routingHardConstraintReasons(&RequestProfile{ToolUseNeed: true, ReasoningNeed: true}, ®istryEntry); len(reasons) != 0 {\n\t\tt.Fatalf(\"注册表已知能力不应触发硬约束: %v\", reasons)\n\t}\n\n\tprofileEntry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 1, Name: \"profile\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_profile\"}, \"messages\", \"unknown-model\", nil,\n\t)\n\tif !profileEntry.SupportsVision || !profileEntry.SupportsToolCalls || !profileEntry.SupportsReasoning {\n\t\tt.Fatalf(\"画像正向能力未合并: %+v\", profileEntry)\n\t}\n}\n"}, "e3e277e0290c136dac3a959462deaae5ed353525": {"t": 1785759327.3178008, "n": 9970, "h": "fb78b88d30413d2a", "c": "package autopilot\n\nimport (\n\t\"math\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/BenedictKing/ccx/internal/errutil\"\n\t\"github.com/BenedictKing/ccx/internal/scheduler\"\n)\n\nfunc TestBuildChannelEntryUsesRegistryCapabilities(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tmodel string\n\t\tupstream config.UpstreamConfig\n\t\twantVision bool\n\t\twantTools bool\n\t\twantReasoning bool\n\t}{\n\t\t{\n\t\t\tname: \"多模态模型使用内置能力\", model: \"mimo-v2.5\",\n\t\t\twantVision: true, wantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"文本模型不会误报视觉\", model: \"mimo-v2.5-pro\",\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"NoVision 强制覆盖注册表\", model: \"mimo-v2.5\",\n\t\t\tupstream: config.UpstreamConfig{NoVision: true},\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"映射后的 NoVisionModels 强制覆盖注册表\", model: \"alias-model\",\n\t\t\tupstream: config.UpstreamConfig{\n\t\t\t\tModelMapping: map[string]string{\"alias-model\": \"mimo-v2.5\"},\n\t\t\t\tNoVisionModels: []string{\"mimo-v2.5\"},\n\t\t\t},\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"渠道能力覆盖可提供正向能力\", model: \"custom-model\",\n\t\t\tupstream: config.UpstreamConfig{ModelCapabilities: map[string]config.UpstreamModelCapability{\n\t\t\t\t\"custom-model\": {\n\t\t\t\t\tThinkingMode: \"thinking\",\n\t\t\t\t\tCapabilities: map[string]bool{\"vision\": true, \"toolCalls\": true},\n\t\t\t\t},\n\t\t\t}},\n\t\t\twantVision: true, wantTools: true, wantReasoning: true,\n\t\t},\n\t}\n\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tupstream := tt.upstream\n\t\t\tupstream.ChannelUID = \"ch_registry\"\n\t\t\tentry := router.buildChannelEntry(\n\t\t\t\tscheduler.ChannelInfo{Index: 0, Name: \"registry\", Status: \"active\"},\n\t\t\t\t&upstream, \"messages\", tt.model, nil,\n\t\t\t)\n\t\t\tif entry.SupportsVision != tt.wantVision || entry.SupportsToolCalls != tt.wantTools ||\n\t\t\t\tentry.SupportsReasoning != tt.wantReasoning {\n\t\t\t\tt.Fatalf(\"capabilities vision=%v tools=%v reasoning=%v, want %v/%v/%v\",\n\t\t\t\t\tentry.SupportsVision, entry.SupportsToolCalls, entry.SupportsReasoning,\n\t\t\t\t\ttt.wantVision, tt.wantTools, tt.wantReasoning)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBuildChannelEntryUsesMappedModelQualityTier(t *testing.T) {\n\tmodelStore := newModelPreviewStore(t,\n\t\tModelProfile{\n\t\t\tChannelUID: \"ch_kimi\", ChannelKind: \"messages\", MetricsKey: \"k3-endpoint\",\n\t\t\tModelID: \"k3\", ModelFamily: ModelFamilyUnknown, QualityTier: QualityTierLow,\n\t\t\tSource: \"auto_discovery\",\n\t\t\tProbeSuccess: true,\n\t\t},\n\t\tModelProfile{\n\t\t\tChannelUID: \"ch_kimi\", ChannelKind: \"messages\", MetricsKey: \"coding-endpoint\",\n\t\t\tModelID: \"kimi-for-coding\", ModelFamily: ModelFamilyKimi, QualityTier: QualityTierNormal,\n\t\t\tSource: \"auto_discovery\",\n\t\t\tProbeSuccess: true,\n\t\t},\n\t)\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\trouter.SetModelProfileStore(modelStore)\n\tupstream := &config.UpstreamConfig{ChannelUID: \"ch_kimi\"}\n\tchannel := scheduler.ChannelInfo{Index: 0, Name: \"kimi\", Status: \"active\"}\n\n\tk3Entry := router.buildChannelEntry(channel, upstream, \"messages\", \"k3\", nil)\n\tcodingEntry := router.buildChannelEntry(channel, upstream, \"messages\", \"kimi-for-coding\", nil)\n\tif k3Entry.ScoringCandidate.QualityTier != QualityTierPremium {\n\t\tt.Fatalf(\"K3 quality tier = %q, want premium\", k3Entry.ScoringCandidate.QualityTier)\n\t}\n\tif codingEntry.ScoringCandidate.QualityTier != QualityTierHigh {\n\t\tt.Fatalf(\"kimi-for-coding quality tier = %q, want high\", codingEntry.ScoringCandidate.QualityTier)\n\t}\n}\n\nfunc TestBuildChannelEntryAppliesProviderTimePricingAfterActivation(t *testing.T) {\n\tmanager, cleanup := createTestConfigManager(t, config.Config{AutopilotRouting: config.DefaultAutopilotRoutingConfig()})\n\tdefer cleanup()\n\trouter := NewSmartRouter(nil, nil, nil, manager)\n\tupstream := &config.UpstreamConfig{ChannelUID: \"ch_deepseek\", ProviderID: \"deepseek\"}\n\tchannel := scheduler.ChannelInfo{Index: 0, Name: \"deepseek\", Status: \"active\"}\n\n\trouter.now = func() time.Time {\n\t\treturn time.Date(2026, 7, 19, 10, 0, 0, 0, time.FixedZone(\"CST\", 8*60*60))\n\t}\n\tbefore := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\trouter.now = func() time.Time {\n\t\treturn time.Date(2026, 7, 20, 10, 0, 0, 0, time.FixedZone(\"CST\", 8*60*60))\n\t}\n\tpeak := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\tif before.EstimatedCost <= 0 || math.Abs(peak.EstimatedCost-before.EstimatedCost*2) > 1e-9 {\n\t\tt.Fatalf(\"estimated cost before=%v peak=%v\", before.EstimatedCost, peak.EstimatedCost)\n\t}\n\n\tupstream.ProviderID = \"\"\n\tupstream.BaseURL = \"https://api.deepseek.com/anthropic\"\n\tmanualOfficial := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\tif math.Abs(manualOfficial.EstimatedCost-peak.EstimatedCost) > 1e-9 {\n\t\tt.Fatalf(\"手动官方 DeepSeek 渠道未应用峰值倍率: managed=%v manual=%v\", peak.EstimatedCost, manualOfficial.EstimatedCost)\n\t}\n\tupstream.BaseURL = \"https://relay.example/v1\"\n\trelay := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\tif math.Abs(relay.EstimatedCost-before.EstimatedCost) > 1e-9 {\n\t\tt.Fatalf(\"第三方 relay 不应应用 DeepSeek 官方倍率: before=%v relay=%v\", before.EstimatedCost, relay.EstimatedCost)\n\t}\n}\n\nfunc TestSmartRouterAppliesCanonicalBenchmarkToDomainScore(t *testing.T) {\n\t// 期望值由当前 registry 推导,避免评测数据刷新导致断言失效。\n\twant := canonicalDomainCeiling(t, \"gpt-5.6-sol\", TaskDomainReasoning)\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\tentry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"sol\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_sol\"}, \"responses\", \"gpt-5.6-sol\", nil,\n\t)\n\tapplyDomainStrength(&entry, TaskDomainReasoning)\n\n\tif math.Abs(entry.ScoringCandidate.DomainStrengthScore-want) > 1e-9 {\n\t\tt.Fatalf(\"DomainStrengthScore = %v, want %v\", entry.ScoringCandidate.DomainStrengthScore, want)\n\t}\n\tif entry.ScoringCandidate.DomainEvidence == nil ||\n\t\tentry.ScoringCandidate.DomainEvidence.Source != \"canonical_benchmark\" ||\n\t\tentry.ScoringCandidate.DomainEvidence.CanonicalModel != \"gpt-5.6-sol\" {\n\t\tt.Fatalf(\"DomainEvidence = %+v\", entry.ScoringCandidate.DomainEvidence)\n\t}\n\n\tscored := ScoreCandidate(entry.ScoringCandidate, ScoringContext{Weights: DefaultTaskWeights()[TaskClassWorker]})\n\tif scored.DomainEvidence == nil || math.Abs(scored.DomainEvidence.CanonicalCeiling-want) > 1e-9 {\n\t\tt.Fatalf(\"scored DomainEvidence = %+v\", scored.DomainEvidence)\n\t}\n}\n\nfunc TestSmartRouterPrefersEndpointDomainOverrideAndAppliesProviderFactor(t *testing.T) {\n\tprofile := &ModelProfile{\n\t\tChannelUID: \"ch_sol\",\n\t\tChannelKind: \"responses\",\n\t\tMetricsKey: \"endpoint-a\",\n\t\tModelID: \"gpt-5.6-sol\",\n\t\tModelFamily: ModelFamilyOpenAI,\n\t\tProviderQualityScore: 0.8,\n\t\tProviderQualityConfidence: 0.75,\n\t}\n\tstore := &ModelProfileStore{cache: map[string]*ModelProfile{\"sol\": profile}}\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\trouter.SetModelProfileStore(store)\n\n\tentry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"sol\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_sol\"}, \"responses\", \"gpt-5.6-sol\", nil,\n\t)\n\tapplyDomainStrength(&entry, TaskDomainReasoning)\n\t// factor = 1 - 0.75 * (1 - 0.8) = 0.85;上界随 registry 刷新动态推导。\n\tif want := canonicalDomainCeiling(t, \"gpt-5.6-sol\", TaskDomainReasoning) * 0.85; math.Abs(entry.ScoringCandidate.DomainStrengthScore-want) > 1e-9 {\n\t\tt.Fatalf(\"quality-adjusted DomainStrengthScore = %v, want %v\", entry.ScoringCandidate.DomainStrengthScore, want)\n\t}\n\n\tprofile.TaskDomainStrengths = map[TaskDomain]float64{TaskDomainReasoning: 0.97}\n\tentry = router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"sol\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_sol\"}, \"responses\", \"gpt-5.6-sol\", nil,\n\t)\n\tapplyDomainStrength(&entry, TaskDomainReasoning)\n\tif entry.ScoringCandidate.DomainStrengthScore != 0.97 ||\n\t\tentry.ScoringCandidate.DomainEvidence.Source != \"endpoint_override\" {\n\t\tt.Fatalf(\"endpoint override evidence = %+v\", entry.ScoringCandidate.DomainEvidence)\n\t}\n}\n\nfunc TestBuildChannelEntryMergesRegistryAndEndpointCapabilities(t *testing.T) {\n\tstore, err := NewProfileStore(filepath.Join(t.TempDir(), \"profiles.db\"))\n\tif err != nil {\n\t\tt.Fatalf(\"创建 ProfileStore 失败: %v\", err)\n\t}\n\tdefer errutil.IgnoreDeferred(store.Close)\n\n\tprofiles := []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_registry\", ChannelUID: \"ch_registry\", ChannelKind: \"messages\",\n\t\t\tHealthState: HealthStateUnknown, QualityTier: QualityTierNormal,\n\t\t\tStabilityTier: StabilityTierNormal, SpeedTier: SpeedTierNormal, CostTier: CostTierNormal,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_profile\", ChannelUID: \"ch_profile\", ChannelKind: \"messages\",\n\t\t\tHealthState: HealthStateUnknown, QualityTier: QualityTierNormal,\n\t\t\tStabilityTier: StabilityTierNormal, SpeedTier: SpeedTierNormal, CostTier: CostTierNormal,\n\t\t\tSupportsVision: true, SupportsToolCalls: true, SupportsReasoning: true,\n\t\t},\n\t}\n\tfor _, profile := range profiles {\n\t\tif err := store.Upsert(profile); err != nil {\n\t\t\tt.Fatalf(\"写入 profile 失败: %v\", err)\n\t\t}\n\t}\n\n\trouter := NewSmartRouter(store, nil, nil, nil)\n\tregistryEntry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"registry\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_registry\"}, \"messages\", \"glm-5.2\", nil,\n\t)\n\tif !registryEntry.SupportsToolCalls || !registryEntry.SupportsReasoning {\n\t\tt.Fatalf(\"空画像不应抹掉注册表能力: %+v\", registryEntry)\n\t}\n\tif reasons := routingHardConstraintReasons(&RequestProfile{ToolUseNeed: true, ReasoningNeed: true}, ®istryEntry); len(reasons) != 0 {\n\t\tt.Fatalf(\"注册表已知能力不应触发硬约束: %v\", reasons)\n\t}\n\n\tprofileEntry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 1, Name: \"profile\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_profile\"}, \"messages\", \"unknown-model\", nil,\n\t)\n\tif !profileEntry.SupportsVision || !profileEntry.SupportsToolCalls || !profileEntry.SupportsReasoning {\n\t\tt.Fatalf(\"画像正向能力未合并: %+v\", profileEntry)\n\t}\n}\n"}, "e2fa5185cf6bd4eb444bd3f89f01e005e323b3e3": {"t": 1785759328.8663528, "n": 9707, "h": "443903b7f65a16bf", "c": "package autopilot\n\nimport (\n\t\"math\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/BenedictKing/ccx/internal/errutil\"\n\t\"github.com/BenedictKing/ccx/internal/scheduler\"\n)\n\nfunc TestBuildChannelEntryUsesRegistryCapabilities(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tmodel string\n\t\tupstream config.UpstreamConfig\n\t\twantVision bool\n\t\twantTools bool\n\t\twantReasoning bool\n\t}{\n\t\t{\n\t\t\tname: \"多模态模型使用内置能力\", model: \"mimo-v2.5\",\n\t\t\twantVision: true, wantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"文本模型不会误报视觉\", model: \"mimo-v2.5-pro\",\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"NoVision 强制覆盖注册表\", model: \"mimo-v2.5\",\n\t\t\tupstream: config.UpstreamConfig{NoVision: true},\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"映射后的 NoVisionModels 强制覆盖注册表\", model: \"alias-model\",\n\t\t\tupstream: config.UpstreamConfig{\n\t\t\t\tModelMapping: map[string]string{\"alias-model\": \"mimo-v2.5\"},\n\t\t\t\tNoVisionModels: []string{\"mimo-v2.5\"},\n\t\t\t},\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"渠道能力覆盖可提供正向能力\", model: \"custom-model\",\n\t\t\tupstream: config.UpstreamConfig{ModelCapabilities: map[string]config.UpstreamModelCapability{\n\t\t\t\t\"custom-model\": {\n\t\t\t\t\tThinkingMode: \"thinking\",\n\t\t\t\t\tCapabilities: map[string]bool{\"vision\": true, \"toolCalls\": true},\n\t\t\t\t},\n\t\t\t}},\n\t\t\twantVision: true, wantTools: true, wantReasoning: true,\n\t\t},\n\t}\n\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tupstream := tt.upstream\n\t\t\tupstream.ChannelUID = \"ch_registry\"\n\t\t\tentry := router.buildChannelEntry(\n\t\t\t\tscheduler.ChannelInfo{Index: 0, Name: \"registry\", Status: \"active\"},\n\t\t\t\t&upstream, \"messages\", tt.model, nil,\n\t\t\t)\n\t\t\tif entry.SupportsVision != tt.wantVision || entry.SupportsToolCalls != tt.wantTools ||\n\t\t\t\tentry.SupportsReasoning != tt.wantReasoning {\n\t\t\t\tt.Fatalf(\"capabilities vision=%v tools=%v reasoning=%v, want %v/%v/%v\",\n\t\t\t\t\tentry.SupportsVision, entry.SupportsToolCalls, entry.SupportsReasoning,\n\t\t\t\t\ttt.wantVision, tt.wantTools, tt.wantReasoning)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBuildChannelEntryUsesMappedModelQualityTier(t *testing.T) {\n\tmodelStore := newModelPreviewStore(t,\n\t\tModelProfile{\n\t\t\tChannelUID: \"ch_kimi\", ChannelKind: \"messages\", MetricsKey: \"k3-endpoint\",\n\t\t\tModelID: \"k3\", ModelFamily: ModelFamilyUnknown, QualityTier: QualityTierLow,\n\t\t\tSource: \"auto_discovery\",\n\t\t\tProbeSuccess: true,\n\t\t},\n\t\tModelProfile{\n\t\t\tChannelUID: \"ch_kimi\", ChannelKind: \"messages\", MetricsKey: \"coding-endpoint\",\n\t\t\tModelID: \"kimi-for-coding\", ModelFamily: ModelFamilyKimi, QualityTier: QualityTierNormal,\n\t\t\tSource: \"auto_discovery\",\n\t\t\tProbeSuccess: true,\n\t\t},\n\t)\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\trouter.SetModelProfileStore(modelStore)\n\tupstream := &config.UpstreamConfig{ChannelUID: \"ch_kimi\"}\n\tchannel := scheduler.ChannelInfo{Index: 0, Name: \"kimi\", Status: \"active\"}\n\n\tk3Entry := router.buildChannelEntry(channel, upstream, \"messages\", \"k3\", nil)\n\tcodingEntry := router.buildChannelEntry(channel, upstream, \"messages\", \"kimi-for-coding\", nil)\n\tif k3Entry.ScoringCandidate.QualityTier != QualityTierPremium {\n\t\tt.Fatalf(\"K3 quality tier = %q, want premium\", k3Entry.ScoringCandidate.QualityTier)\n\t}\n\tif codingEntry.ScoringCandidate.QualityTier != QualityTierHigh {\n\t\tt.Fatalf(\"kimi-for-coding quality tier = %q, want high\", codingEntry.ScoringCandidate.QualityTier)\n\t}\n}\n\nfunc TestBuildChannelEntryAppliesProviderTimePricingAfterActivation(t *testing.T) {\n\tmanager, cleanup := createTestConfigManager(t, config.Config{AutopilotRouting: config.DefaultAutopilotRoutingConfig()})\n\tdefer cleanup()\n\trouter := NewSmartRouter(nil, nil, nil, manager)\n\tupstream := &config.UpstreamConfig{ChannelUID: \"ch_deepseek\", ProviderID: \"deepseek\"}\n\tchannel := scheduler.ChannelInfo{Index: 0, Name: \"deepseek\", Status: \"active\"}\n\n\trouter.now = func() time.Time {\n\t\treturn time.Date(2026, 7, 19, 10, 0, 0, 0, time.FixedZone(\"CST\", 8*60*60))\n\t}\n\tbefore := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\trouter.now = func() time.Time {\n\t\treturn time.Date(2026, 7, 20, 10, 0, 0, 0, time.FixedZone(\"CST\", 8*60*60))\n\t}\n\tpeak := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\tif before.EstimatedCost <= 0 || math.Abs(peak.EstimatedCost-before.EstimatedCost*2) > 1e-9 {\n\t\tt.Fatalf(\"estimated cost before=%v peak=%v\", before.EstimatedCost, peak.EstimatedCost)\n\t}\n\n\tupstream.ProviderID = \"\"\n\tupstream.BaseURL = \"https://api.deepseek.com/anthropic\"\n\tmanualOfficial := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\tif math.Abs(manualOfficial.EstimatedCost-peak.EstimatedCost) > 1e-9 {\n\t\tt.Fatalf(\"手动官方 DeepSeek 渠道未应用峰值倍率: managed=%v manual=%v\", peak.EstimatedCost, manualOfficial.EstimatedCost)\n\t}\n\tupstream.BaseURL = \"https://relay.example/v1\"\n\trelay := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\tif math.Abs(relay.EstimatedCost-before.EstimatedCost) > 1e-9 {\n\t\tt.Fatalf(\"第三方 relay 不应应用 DeepSeek 官方倍率: before=%v relay=%v\", before.EstimatedCost, relay.EstimatedCost)\n\t}\n}\n\nfunc TestSmartRouterAppliesCanonicalBenchmarkToDomainScore(t *testing.T) {\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\tentry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"sol\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_sol\"}, \"responses\", \"gpt-5.6-sol\", nil,\n\t)\n\tapplyDomainStrength(&entry, TaskDomainReasoning)\n\n\tif math.Abs(entry.ScoringCandidate.DomainStrengthScore-0.971) > 1e-9 {\n\t\tt.Fatalf(\"DomainStrengthScore = %v, want 0.971\", entry.ScoringCandidate.DomainStrengthScore)\n\t}\n\tif entry.ScoringCandidate.DomainEvidence == nil ||\n\t\tentry.ScoringCandidate.DomainEvidence.Source != \"canonical_benchmark\" ||\n\t\tentry.ScoringCandidate.DomainEvidence.CanonicalModel != \"gpt-5.6-sol\" {\n\t\tt.Fatalf(\"DomainEvidence = %+v\", entry.ScoringCandidate.DomainEvidence)\n\t}\n\n\tscored := ScoreCandidate(entry.ScoringCandidate, ScoringContext{Weights: DefaultTaskWeights()[TaskClassWorker]})\n\tif scored.DomainEvidence == nil || scored.DomainEvidence.CanonicalCeiling != 0.971 {\n\t\tt.Fatalf(\"scored DomainEvidence = %+v\", scored.DomainEvidence)\n\t}\n}\n\nfunc TestSmartRouterPrefersEndpointDomainOverrideAndAppliesProviderFactor(t *testing.T) {\n\tprofile := &ModelProfile{\n\t\tChannelUID: \"ch_sol\",\n\t\tChannelKind: \"responses\",\n\t\tMetricsKey: \"endpoint-a\",\n\t\tModelID: \"gpt-5.6-sol\",\n\t\tModelFamily: ModelFamilyOpenAI,\n\t\tProviderQualityScore: 0.8,\n\t\tProviderQualityConfidence: 0.75,\n\t}\n\tstore := &ModelProfileStore{cache: map[string]*ModelProfile{\"sol\": profile}}\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\trouter.SetModelProfileStore(store)\n\n\tentry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"sol\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_sol\"}, \"responses\", \"gpt-5.6-sol\", nil,\n\t)\n\tapplyDomainStrength(&entry, TaskDomainReasoning)\n\tif math.Abs(entry.ScoringCandidate.DomainStrengthScore-0.82535) > 1e-9 {\n\t\tt.Fatalf(\"quality-adjusted DomainStrengthScore = %v, want 0.82535\", entry.ScoringCandidate.DomainStrengthScore)\n\t}\n\n\tprofile.TaskDomainStrengths = map[TaskDomain]float64{TaskDomainReasoning: 0.97}\n\tentry = router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"sol\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_sol\"}, \"responses\", \"gpt-5.6-sol\", nil,\n\t)\n\tapplyDomainStrength(&entry, TaskDomainReasoning)\n\tif entry.ScoringCandidate.DomainStrengthScore != 0.97 ||\n\t\tentry.ScoringCandidate.DomainEvidence.Source != \"endpoint_override\" {\n\t\tt.Fatalf(\"endpoint override evidence = %+v\", entry.ScoringCandidate.DomainEvidence)\n\t}\n}\n\nfunc TestBuildChannelEntryMergesRegistryAndEndpointCapabilities(t *testing.T) {\n\tstore, err := NewProfileStore(filepath.Join(t.TempDir(), \"profiles.db\"))\n\tif err != nil {\n\t\tt.Fatalf(\"创建 ProfileStore 失败: %v\", err)\n\t}\n\tdefer errutil.IgnoreDeferred(store.Close)\n\n\tprofiles := []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_registry\", ChannelUID: \"ch_registry\", ChannelKind: \"messages\",\n\t\t\tHealthState: HealthStateUnknown, QualityTier: QualityTierNormal,\n\t\t\tStabilityTier: StabilityTierNormal, SpeedTier: SpeedTierNormal, CostTier: CostTierNormal,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_profile\", ChannelUID: \"ch_profile\", ChannelKind: \"messages\",\n\t\t\tHealthState: HealthStateUnknown, QualityTier: QualityTierNormal,\n\t\t\tStabilityTier: StabilityTierNormal, SpeedTier: SpeedTierNormal, CostTier: CostTierNormal,\n\t\t\tSupportsVision: true, SupportsToolCalls: true, SupportsReasoning: true,\n\t\t},\n\t}\n\tfor _, profile := range profiles {\n\t\tif err := store.Upsert(profile); err != nil {\n\t\t\tt.Fatalf(\"写入 profile 失败: %v\", err)\n\t\t}\n\t}\n\n\trouter := NewSmartRouter(store, nil, nil, nil)\n\tregistryEntry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"registry\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_registry\"}, \"messages\", \"glm-5.2\", nil,\n\t)\n\tif !registryEntry.SupportsToolCalls || !registryEntry.SupportsReasoning {\n\t\tt.Fatalf(\"空画像不应抹掉注册表能力: %+v\", registryEntry)\n\t}\n\tif reasons := routingHardConstraintReasons(&RequestProfile{ToolUseNeed: true, ReasoningNeed: true}, ®istryEntry); len(reasons) != 0 {\n\t\tt.Fatalf(\"注册表已知能力不应触发硬约束: %v\", reasons)\n\t}\n\n\tprofileEntry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 1, Name: \"profile\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_profile\"}, \"messages\", \"unknown-model\", nil,\n\t)\n\tif !profileEntry.SupportsVision || !profileEntry.SupportsToolCalls || !profileEntry.SupportsReasoning {\n\t\tt.Fatalf(\"画像正向能力未合并: %+v\", profileEntry)\n\t}\n}\n"}, "6a6b2055828ba187fd85475333e90bf7166f183c": {"t": 1785759330.5288057, "n": 9707, "h": "83f9946466e1c954", "c": "package autopilot\n\nimport (\n\t\"math\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/BenedictKing/ccx/internal/errutil\"\n\t\"github.com/BenedictKing/ccx/internal/scheduler\"\n)\n\nfunc TestBuildChannelEntryUsesRegistryCapabilities(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tmodel string\n\t\tupstream config.UpstreamConfig\n\t\twantVision bool\n\t\twantTools bool\n\t\twantReasoning bool\n\t}{\n\t\t{\n\t\t\tname: \"多模态模型使用内置能力\", model: \"mimo-v2.5\",\n\t\t\twantVision: true, wantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"文本模型不会误报视觉\", model: \"mimo-v2.5-pro\",\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"NoVision 强制覆盖注册表\", model: \"mimo-v2.5\",\n\t\t\tupstream: config.UpstreamConfig{NoVision: true},\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"映射后的 NoVisionModels 强制覆盖注册表\", model: \"alias-model\",\n\t\t\tupstream: config.UpstreamConfig{\n\t\t\t\tModelMapping: map[string]string{\"alias-model\": \"mimo-v2.5\"},\n\t\t\t\tNoVisionModels: []string{\"mimo-v2.5\"},\n\t\t\t},\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"渠道能力覆盖可提供正向能力\", model: \"custom-model\",\n\t\t\tupstream: config.UpstreamConfig{ModelCapabilities: map[string]config.UpstreamModelCapability{\n\t\t\t\t\"custom-model\": {\n\t\t\t\t\tThinkingMode: \"thinking\",\n\t\t\t\t\tCapabilities: map[string]bool{\"vision\": true, \"toolCalls\": true},\n\t\t\t\t},\n\t\t\t}},\n\t\t\twantVision: true, wantTools: true, wantReasoning: true,\n\t\t},\n\t}\n\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tupstream := tt.upstream\n\t\t\tupstream.ChannelUID = \"ch_registry\"\n\t\t\tentry := router.buildChannelEntry(\n\t\t\t\tscheduler.ChannelInfo{Index: 0, Name: \"registry\", Status: \"active\"},\n\t\t\t\t&upstream, \"messages\", tt.model, nil,\n\t\t\t)\n\t\t\tif entry.SupportsVision != tt.wantVision || entry.SupportsToolCalls != tt.wantTools ||\n\t\t\t\tentry.SupportsReasoning != tt.wantReasoning {\n\t\t\t\tt.Fatalf(\"capabilities vision=%v tools=%v reasoning=%v, want %v/%v/%v\",\n\t\t\t\t\tentry.SupportsVision, entry.SupportsToolCalls, entry.SupportsReasoning,\n\t\t\t\t\ttt.wantVision, tt.wantTools, tt.wantReasoning)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBuildChannelEntryUsesMappedModelQualityTier(t *testing.T) {\n\tmodelStore := newModelPreviewStore(t,\n\t\tModelProfile{\n\t\t\tChannelUID: \"ch_kimi\", ChannelKind: \"messages\", MetricsKey: \"k3-endpoint\",\n\t\t\tModelID: \"k3\", ModelFamily: ModelFamilyUnknown, QualityTier: QualityTierLow,\n\t\t\tSource: \"auto_discovery\",\n\t\t\tProbeSuccess: true,\n\t\t},\n\t\tModelProfile{\n\t\t\tChannelUID: \"ch_kimi\", ChannelKind: \"messages\", MetricsKey: \"coding-endpoint\",\n\t\t\tModelID: \"kimi-for-coding\", ModelFamily: ModelFamilyKimi, QualityTier: QualityTierNormal,\n\t\t\tSource: \"auto_discovery\",\n\t\t\tProbeSuccess: true,\n\t\t},\n\t)\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\trouter.SetModelProfileStore(modelStore)\n\tupstream := &config.UpstreamConfig{ChannelUID: \"ch_kimi\"}\n\tchannel := scheduler.ChannelInfo{Index: 0, Name: \"kimi\", Status: \"active\"}\n\n\tk3Entry := router.buildChannelEntry(channel, upstream, \"messages\", \"k3\", nil)\n\tcodingEntry := router.buildChannelEntry(channel, upstream, \"messages\", \"kimi-for-coding\", nil)\n\tif k3Entry.ScoringCandidate.QualityTier != QualityTierPremium {\n\t\tt.Fatalf(\"K3 quality tier = %q, want premium\", k3Entry.ScoringCandidate.QualityTier)\n\t}\n\tif codingEntry.ScoringCandidate.QualityTier != QualityTierHigh {\n\t\tt.Fatalf(\"kimi-for-coding quality tier = %q, want high\", codingEntry.ScoringCandidate.QualityTier)\n\t}\n}\n\nfunc TestBuildChannelEntryAppliesProviderTimePricingAfterActivation(t *testing.T) {\n\tmanager, cleanup := createTestConfigManager(t, config.Config{AutopilotRouting: config.DefaultAutopilotRoutingConfig()})\n\tdefer cleanup()\n\trouter := NewSmartRouter(nil, nil, nil, manager)\n\tupstream := &config.UpstreamConfig{ChannelUID: \"ch_deepseek\", ProviderID: \"deepseek\"}\n\tchannel := scheduler.ChannelInfo{Index: 0, Name: \"deepseek\", Status: \"active\"}\n\n\trouter.now = func() time.Time {\n\t\treturn time.Date(2026, 7, 19, 10, 0, 0, 0, time.FixedZone(\"CST\", 8*60*60))\n\t}\n\tbefore := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\trouter.now = func() time.Time {\n\t\treturn time.Date(2026, 7, 20, 10, 0, 0, 0, time.FixedZone(\"CST\", 8*60*60))\n\t}\n\tpeak := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\tif before.EstimatedCost <= 0 || math.Abs(peak.EstimatedCost-before.EstimatedCost*2) > 1e-9 {\n\t\tt.Fatalf(\"estimated cost before=%v peak=%v\", before.EstimatedCost, peak.EstimatedCost)\n\t}\n\n\tupstream.ProviderID = \"\"\n\tupstream.BaseURL = \"https://api.deepseek.com/anthropic\"\n\tmanualOfficial := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\tif math.Abs(manualOfficial.EstimatedCost-peak.EstimatedCost) > 1e-9 {\n\t\tt.Fatalf(\"手动官方 DeepSeek 渠道未应用峰值倍率: managed=%v manual=%v\", peak.EstimatedCost, manualOfficial.EstimatedCost)\n\t}\n\tupstream.BaseURL = \"https://relay.example/v1\"\n\trelay := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\tif math.Abs(relay.EstimatedCost-before.EstimatedCost) > 1e-9 {\n\t\tt.Fatalf(\"第三方 relay 不应应用 DeepSeek 官方倍率: before=%v relay=%v\", before.EstimatedCost, relay.EstimatedCost)\n\t}\n}\n\nfunc TestSmartRouterAppliesCanonicalBenchmarkToDomainScore(t *testing.T) {\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\tentry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"sol\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_sol\"}, \"responses\", \"gpt-5.6-sol\", nil,\n\t)\n\tapplyDomainStrength(&entry, TaskDomainReasoning)\n\n\tif math.Abs(entry.ScoringCandidate.DomainStrengthScore-0.875) > 1e-9 {\n\t\tt.Fatalf(\"DomainStrengthScore = %v, want 0.875\", entry.ScoringCandidate.DomainStrengthScore)\n\t}\n\tif entry.ScoringCandidate.DomainEvidence == nil ||\n\t\tentry.ScoringCandidate.DomainEvidence.Source != \"canonical_benchmark\" ||\n\t\tentry.ScoringCandidate.DomainEvidence.CanonicalModel != \"gpt-5.6-sol\" {\n\t\tt.Fatalf(\"DomainEvidence = %+v\", entry.ScoringCandidate.DomainEvidence)\n\t}\n\n\tscored := ScoreCandidate(entry.ScoringCandidate, ScoringContext{Weights: DefaultTaskWeights()[TaskClassWorker]})\n\tif scored.DomainEvidence == nil || scored.DomainEvidence.CanonicalCeiling != 0.875 {\n\t\tt.Fatalf(\"scored DomainEvidence = %+v\", scored.DomainEvidence)\n\t}\n}\n\nfunc TestSmartRouterPrefersEndpointDomainOverrideAndAppliesProviderFactor(t *testing.T) {\n\tprofile := &ModelProfile{\n\t\tChannelUID: \"ch_sol\",\n\t\tChannelKind: \"responses\",\n\t\tMetricsKey: \"endpoint-a\",\n\t\tModelID: \"gpt-5.6-sol\",\n\t\tModelFamily: ModelFamilyOpenAI,\n\t\tProviderQualityScore: 0.8,\n\t\tProviderQualityConfidence: 0.75,\n\t}\n\tstore := &ModelProfileStore{cache: map[string]*ModelProfile{\"sol\": profile}}\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\trouter.SetModelProfileStore(store)\n\n\tentry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"sol\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_sol\"}, \"responses\", \"gpt-5.6-sol\", nil,\n\t)\n\tapplyDomainStrength(&entry, TaskDomainReasoning)\n\tif math.Abs(entry.ScoringCandidate.DomainStrengthScore-0.74375) > 1e-9 {\n\t\tt.Fatalf(\"quality-adjusted DomainStrengthScore = %v, want 0.74375\", entry.ScoringCandidate.DomainStrengthScore)\n\t}\n\n\tprofile.TaskDomainStrengths = map[TaskDomain]float64{TaskDomainReasoning: 0.97}\n\tentry = router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"sol\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_sol\"}, \"responses\", \"gpt-5.6-sol\", nil,\n\t)\n\tapplyDomainStrength(&entry, TaskDomainReasoning)\n\tif entry.ScoringCandidate.DomainStrengthScore != 0.97 ||\n\t\tentry.ScoringCandidate.DomainEvidence.Source != \"endpoint_override\" {\n\t\tt.Fatalf(\"endpoint override evidence = %+v\", entry.ScoringCandidate.DomainEvidence)\n\t}\n}\n\nfunc TestBuildChannelEntryMergesRegistryAndEndpointCapabilities(t *testing.T) {\n\tstore, err := NewProfileStore(filepath.Join(t.TempDir(), \"profiles.db\"))\n\tif err != nil {\n\t\tt.Fatalf(\"创建 ProfileStore 失败: %v\", err)\n\t}\n\tdefer errutil.IgnoreDeferred(store.Close)\n\n\tprofiles := []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_registry\", ChannelUID: \"ch_registry\", ChannelKind: \"messages\",\n\t\t\tHealthState: HealthStateUnknown, QualityTier: QualityTierNormal,\n\t\t\tStabilityTier: StabilityTierNormal, SpeedTier: SpeedTierNormal, CostTier: CostTierNormal,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_profile\", ChannelUID: \"ch_profile\", ChannelKind: \"messages\",\n\t\t\tHealthState: HealthStateUnknown, QualityTier: QualityTierNormal,\n\t\t\tStabilityTier: StabilityTierNormal, SpeedTier: SpeedTierNormal, CostTier: CostTierNormal,\n\t\t\tSupportsVision: true, SupportsToolCalls: true, SupportsReasoning: true,\n\t\t},\n\t}\n\tfor _, profile := range profiles {\n\t\tif err := store.Upsert(profile); err != nil {\n\t\t\tt.Fatalf(\"写入 profile 失败: %v\", err)\n\t\t}\n\t}\n\n\trouter := NewSmartRouter(store, nil, nil, nil)\n\tregistryEntry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"registry\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_registry\"}, \"messages\", \"glm-5.2\", nil,\n\t)\n\tif !registryEntry.SupportsToolCalls || !registryEntry.SupportsReasoning {\n\t\tt.Fatalf(\"空画像不应抹掉注册表能力: %+v\", registryEntry)\n\t}\n\tif reasons := routingHardConstraintReasons(&RequestProfile{ToolUseNeed: true, ReasoningNeed: true}, ®istryEntry); len(reasons) != 0 {\n\t\tt.Fatalf(\"注册表已知能力不应触发硬约束: %v\", reasons)\n\t}\n\n\tprofileEntry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 1, Name: \"profile\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_profile\"}, \"messages\", \"unknown-model\", nil,\n\t)\n\tif !profileEntry.SupportsVision || !profileEntry.SupportsToolCalls || !profileEntry.SupportsReasoning {\n\t\tt.Fatalf(\"画像正向能力未合并: %+v\", profileEntry)\n\t}\n}\n"}, "3e91f60bb5bd0052bb655610405b2563d3774e0d": {"t": 1785759332.070955, "n": 9637, "h": "e024d915fa46e87a", "c": "package autopilot\n\nimport (\n\t\"math\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/BenedictKing/ccx/internal/scheduler\"\n)\n\nfunc TestBuildChannelEntryUsesRegistryCapabilities(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tmodel string\n\t\tupstream config.UpstreamConfig\n\t\twantVision bool\n\t\twantTools bool\n\t\twantReasoning bool\n\t}{\n\t\t{\n\t\t\tname: \"多模态模型使用内置能力\", model: \"mimo-v2.5\",\n\t\t\twantVision: true, wantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"文本模型不会误报视觉\", model: \"mimo-v2.5-pro\",\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"NoVision 强制覆盖注册表\", model: \"mimo-v2.5\",\n\t\t\tupstream: config.UpstreamConfig{NoVision: true},\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"映射后的 NoVisionModels 强制覆盖注册表\", model: \"alias-model\",\n\t\t\tupstream: config.UpstreamConfig{\n\t\t\t\tModelMapping: map[string]string{\"alias-model\": \"mimo-v2.5\"},\n\t\t\t\tNoVisionModels: []string{\"mimo-v2.5\"},\n\t\t\t},\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"渠道能力覆盖可提供正向能力\", model: \"custom-model\",\n\t\t\tupstream: config.UpstreamConfig{ModelCapabilities: map[string]config.UpstreamModelCapability{\n\t\t\t\t\"custom-model\": {\n\t\t\t\t\tThinkingMode: \"thinking\",\n\t\t\t\t\tCapabilities: map[string]bool{\"vision\": true, \"toolCalls\": true},\n\t\t\t\t},\n\t\t\t}},\n\t\t\twantVision: true, wantTools: true, wantReasoning: true,\n\t\t},\n\t}\n\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tupstream := tt.upstream\n\t\t\tupstream.ChannelUID = \"ch_registry\"\n\t\t\tentry := router.buildChannelEntry(\n\t\t\t\tscheduler.ChannelInfo{Index: 0, Name: \"registry\", Status: \"active\"},\n\t\t\t\t&upstream, \"messages\", tt.model, nil,\n\t\t\t)\n\t\t\tif entry.SupportsVision != tt.wantVision || entry.SupportsToolCalls != tt.wantTools ||\n\t\t\t\tentry.SupportsReasoning != tt.wantReasoning {\n\t\t\t\tt.Fatalf(\"capabilities vision=%v tools=%v reasoning=%v, want %v/%v/%v\",\n\t\t\t\t\tentry.SupportsVision, entry.SupportsToolCalls, entry.SupportsReasoning,\n\t\t\t\t\ttt.wantVision, tt.wantTools, tt.wantReasoning)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBuildChannelEntryUsesMappedModelQualityTier(t *testing.T) {\n\tmodelStore := newModelPreviewStore(t,\n\t\tModelProfile{\n\t\t\tChannelUID: \"ch_kimi\", ChannelKind: \"messages\", MetricsKey: \"k3-endpoint\",\n\t\t\tModelID: \"k3\", ModelFamily: ModelFamilyUnknown, QualityTier: QualityTierLow,\n\t\t\tSource: \"auto_discovery\",\n\t\t\tProbeSuccess: true,\n\t\t},\n\t\tModelProfile{\n\t\t\tChannelUID: \"ch_kimi\", ChannelKind: \"messages\", MetricsKey: \"coding-endpoint\",\n\t\t\tModelID: \"kimi-for-coding\", ModelFamily: ModelFamilyKimi, QualityTier: QualityTierNormal,\n\t\t\tSource: \"auto_discovery\",\n\t\t\tProbeSuccess: true,\n\t\t},\n\t)\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\trouter.SetModelProfileStore(modelStore)\n\tupstream := &config.UpstreamConfig{ChannelUID: \"ch_kimi\"}\n\tchannel := scheduler.ChannelInfo{Index: 0, Name: \"kimi\", Status: \"active\"}\n\n\tk3Entry := router.buildChannelEntry(channel, upstream, \"messages\", \"k3\", nil)\n\tcodingEntry := router.buildChannelEntry(channel, upstream, \"messages\", \"kimi-for-coding\", nil)\n\tif k3Entry.ScoringCandidate.QualityTier != QualityTierPremium {\n\t\tt.Fatalf(\"K3 quality tier = %q, want premium\", k3Entry.ScoringCandidate.QualityTier)\n\t}\n\tif codingEntry.ScoringCandidate.QualityTier != QualityTierHigh {\n\t\tt.Fatalf(\"kimi-for-coding quality tier = %q, want high\", codingEntry.ScoringCandidate.QualityTier)\n\t}\n}\n\nfunc TestBuildChannelEntryAppliesProviderTimePricingAfterActivation(t *testing.T) {\n\tmanager, cleanup := createTestConfigManager(t, config.Config{AutopilotRouting: config.DefaultAutopilotRoutingConfig()})\n\tdefer cleanup()\n\trouter := NewSmartRouter(nil, nil, nil, manager)\n\tupstream := &config.UpstreamConfig{ChannelUID: \"ch_deepseek\", ProviderID: \"deepseek\"}\n\tchannel := scheduler.ChannelInfo{Index: 0, Name: \"deepseek\", Status: \"active\"}\n\n\trouter.now = func() time.Time {\n\t\treturn time.Date(2026, 7, 19, 10, 0, 0, 0, time.FixedZone(\"CST\", 8*60*60))\n\t}\n\tbefore := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\trouter.now = func() time.Time {\n\t\treturn time.Date(2026, 7, 20, 10, 0, 0, 0, time.FixedZone(\"CST\", 8*60*60))\n\t}\n\tpeak := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\tif before.EstimatedCost <= 0 || math.Abs(peak.EstimatedCost-before.EstimatedCost*2) > 1e-9 {\n\t\tt.Fatalf(\"estimated cost before=%v peak=%v\", before.EstimatedCost, peak.EstimatedCost)\n\t}\n\n\tupstream.ProviderID = \"\"\n\tupstream.BaseURL = \"https://api.deepseek.com/anthropic\"\n\tmanualOfficial := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\tif math.Abs(manualOfficial.EstimatedCost-peak.EstimatedCost) > 1e-9 {\n\t\tt.Fatalf(\"手动官方 DeepSeek 渠道未应用峰值倍率: managed=%v manual=%v\", peak.EstimatedCost, manualOfficial.EstimatedCost)\n\t}\n\tupstream.BaseURL = \"https://relay.example/v1\"\n\trelay := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\tif math.Abs(relay.EstimatedCost-before.EstimatedCost) > 1e-9 {\n\t\tt.Fatalf(\"第三方 relay 不应应用 DeepSeek 官方倍率: before=%v relay=%v\", before.EstimatedCost, relay.EstimatedCost)\n\t}\n}\n\nfunc TestSmartRouterAppliesCanonicalBenchmarkToDomainScore(t *testing.T) {\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\tentry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"sol\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_sol\"}, \"responses\", \"gpt-5.6-sol\", nil,\n\t)\n\tapplyDomainStrength(&entry, TaskDomainReasoning)\n\n\tif math.Abs(entry.ScoringCandidate.DomainStrengthScore-0.875) > 1e-9 {\n\t\tt.Fatalf(\"DomainStrengthScore = %v, want 0.875\", entry.ScoringCandidate.DomainStrengthScore)\n\t}\n\tif entry.ScoringCandidate.DomainEvidence == nil ||\n\t\tentry.ScoringCandidate.DomainEvidence.Source != \"canonical_benchmark\" ||\n\t\tentry.ScoringCandidate.DomainEvidence.CanonicalModel != \"gpt-5.6-sol\" {\n\t\tt.Fatalf(\"DomainEvidence = %+v\", entry.ScoringCandidate.DomainEvidence)\n\t}\n\n\tscored := ScoreCandidate(entry.ScoringCandidate, ScoringContext{Weights: DefaultTaskWeights()[TaskClassWorker]})\n\tif scored.DomainEvidence == nil || scored.DomainEvidence.CanonicalCeiling != 0.875 {\n\t\tt.Fatalf(\"scored DomainEvidence = %+v\", scored.DomainEvidence)\n\t}\n}\n\nfunc TestSmartRouterPrefersEndpointDomainOverrideAndAppliesProviderFactor(t *testing.T) {\n\tprofile := &ModelProfile{\n\t\tChannelUID: \"ch_sol\",\n\t\tChannelKind: \"responses\",\n\t\tMetricsKey: \"endpoint-a\",\n\t\tModelID: \"gpt-5.6-sol\",\n\t\tModelFamily: ModelFamilyOpenAI,\n\t\tProviderQualityScore: 0.8,\n\t\tProviderQualityConfidence: 0.75,\n\t}\n\tstore := &ModelProfileStore{cache: map[string]*ModelProfile{\"sol\": profile}}\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\trouter.SetModelProfileStore(store)\n\n\tentry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"sol\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_sol\"}, \"responses\", \"gpt-5.6-sol\", nil,\n\t)\n\tapplyDomainStrength(&entry, TaskDomainReasoning)\n\tif math.Abs(entry.ScoringCandidate.DomainStrengthScore-0.74375) > 1e-9 {\n\t\tt.Fatalf(\"quality-adjusted DomainStrengthScore = %v, want 0.74375\", entry.ScoringCandidate.DomainStrengthScore)\n\t}\n\n\tprofile.TaskDomainStrengths = map[TaskDomain]float64{TaskDomainReasoning: 0.97}\n\tentry = router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"sol\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_sol\"}, \"responses\", \"gpt-5.6-sol\", nil,\n\t)\n\tapplyDomainStrength(&entry, TaskDomainReasoning)\n\tif entry.ScoringCandidate.DomainStrengthScore != 0.97 ||\n\t\tentry.ScoringCandidate.DomainEvidence.Source != \"endpoint_override\" {\n\t\tt.Fatalf(\"endpoint override evidence = %+v\", entry.ScoringCandidate.DomainEvidence)\n\t}\n}\n\nfunc TestBuildChannelEntryMergesRegistryAndEndpointCapabilities(t *testing.T) {\n\tstore, err := NewProfileStore(filepath.Join(t.TempDir(), \"profiles.db\"))\n\tif err != nil {\n\t\tt.Fatalf(\"创建 ProfileStore 失败: %v\", err)\n\t}\n\tdefer store.Close()\n\n\tprofiles := []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_registry\", ChannelUID: \"ch_registry\", ChannelKind: \"messages\",\n\t\t\tHealthState: HealthStateUnknown, QualityTier: QualityTierNormal,\n\t\t\tStabilityTier: StabilityTierNormal, SpeedTier: SpeedTierNormal, CostTier: CostTierNormal,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_profile\", ChannelUID: \"ch_profile\", ChannelKind: \"messages\",\n\t\t\tHealthState: HealthStateUnknown, QualityTier: QualityTierNormal,\n\t\t\tStabilityTier: StabilityTierNormal, SpeedTier: SpeedTierNormal, CostTier: CostTierNormal,\n\t\t\tSupportsVision: true, SupportsToolCalls: true, SupportsReasoning: true,\n\t\t},\n\t}\n\tfor _, profile := range profiles {\n\t\tif err := store.Upsert(profile); err != nil {\n\t\t\tt.Fatalf(\"写入 profile 失败: %v\", err)\n\t\t}\n\t}\n\n\trouter := NewSmartRouter(store, nil, nil, nil)\n\tregistryEntry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"registry\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_registry\"}, \"messages\", \"glm-5.2\", nil,\n\t)\n\tif !registryEntry.SupportsToolCalls || !registryEntry.SupportsReasoning {\n\t\tt.Fatalf(\"空画像不应抹掉注册表能力: %+v\", registryEntry)\n\t}\n\tif reasons := routingHardConstraintReasons(&RequestProfile{ToolUseNeed: true, ReasoningNeed: true}, ®istryEntry); len(reasons) != 0 {\n\t\tt.Fatalf(\"注册表已知能力不应触发硬约束: %v\", reasons)\n\t}\n\n\tprofileEntry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 1, Name: \"profile\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_profile\"}, \"messages\", \"unknown-model\", nil,\n\t)\n\tif !profileEntry.SupportsVision || !profileEntry.SupportsToolCalls || !profileEntry.SupportsReasoning {\n\t\tt.Fatalf(\"画像正向能力未合并: %+v\", profileEntry)\n\t}\n}\n"}, "5d2ea67008b8062dd3b4f7104a16827e50c393b3": {"t": 1785759333.711116, "n": 8309, "h": "efdb060735988775", "c": "package autopilot\n\nimport (\n\t\"math\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/BenedictKing/ccx/internal/scheduler\"\n)\n\nfunc TestBuildChannelEntryUsesRegistryCapabilities(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tmodel string\n\t\tupstream config.UpstreamConfig\n\t\twantVision bool\n\t\twantTools bool\n\t\twantReasoning bool\n\t}{\n\t\t{\n\t\t\tname: \"多模态模型使用内置能力\", model: \"mimo-v2.5\",\n\t\t\twantVision: true, wantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"文本模型不会误报视觉\", model: \"mimo-v2.5-pro\",\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"NoVision 强制覆盖注册表\", model: \"mimo-v2.5\",\n\t\t\tupstream: config.UpstreamConfig{NoVision: true},\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"映射后的 NoVisionModels 强制覆盖注册表\", model: \"alias-model\",\n\t\t\tupstream: config.UpstreamConfig{\n\t\t\t\tModelMapping: map[string]string{\"alias-model\": \"mimo-v2.5\"},\n\t\t\t\tNoVisionModels: []string{\"mimo-v2.5\"},\n\t\t\t},\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"渠道能力覆盖可提供正向能力\", model: \"custom-model\",\n\t\t\tupstream: config.UpstreamConfig{ModelCapabilities: map[string]config.UpstreamModelCapability{\n\t\t\t\t\"custom-model\": {\n\t\t\t\t\tThinkingMode: \"thinking\",\n\t\t\t\t\tCapabilities: map[string]bool{\"vision\": true, \"toolCalls\": true},\n\t\t\t\t},\n\t\t\t}},\n\t\t\twantVision: true, wantTools: true, wantReasoning: true,\n\t\t},\n\t}\n\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tupstream := tt.upstream\n\t\t\tupstream.ChannelUID = \"ch_registry\"\n\t\t\tentry := router.buildChannelEntry(\n\t\t\t\tscheduler.ChannelInfo{Index: 0, Name: \"registry\", Status: \"active\"},\n\t\t\t\t&upstream, \"messages\", tt.model, nil,\n\t\t\t)\n\t\t\tif entry.SupportsVision != tt.wantVision || entry.SupportsToolCalls != tt.wantTools ||\n\t\t\t\tentry.SupportsReasoning != tt.wantReasoning {\n\t\t\t\tt.Fatalf(\"capabilities vision=%v tools=%v reasoning=%v, want %v/%v/%v\",\n\t\t\t\t\tentry.SupportsVision, entry.SupportsToolCalls, entry.SupportsReasoning,\n\t\t\t\t\ttt.wantVision, tt.wantTools, tt.wantReasoning)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBuildChannelEntryAppliesProviderTimePricingAfterActivation(t *testing.T) {\n\tmanager, cleanup := createTestConfigManager(t, config.Config{AutopilotRouting: config.DefaultAutopilotRoutingConfig()})\n\tdefer cleanup()\n\trouter := NewSmartRouter(nil, nil, nil, manager)\n\tupstream := &config.UpstreamConfig{ChannelUID: \"ch_deepseek\", ProviderID: \"deepseek\"}\n\tchannel := scheduler.ChannelInfo{Index: 0, Name: \"deepseek\", Status: \"active\"}\n\n\trouter.now = func() time.Time {\n\t\treturn time.Date(2026, 7, 19, 10, 0, 0, 0, time.FixedZone(\"CST\", 8*60*60))\n\t}\n\tbefore := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\trouter.now = func() time.Time {\n\t\treturn time.Date(2026, 7, 20, 10, 0, 0, 0, time.FixedZone(\"CST\", 8*60*60))\n\t}\n\tpeak := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\tif before.EstimatedCost <= 0 || math.Abs(peak.EstimatedCost-before.EstimatedCost*2) > 1e-9 {\n\t\tt.Fatalf(\"estimated cost before=%v peak=%v\", before.EstimatedCost, peak.EstimatedCost)\n\t}\n\n\tupstream.ProviderID = \"\"\n\tupstream.BaseURL = \"https://api.deepseek.com/anthropic\"\n\tmanualOfficial := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\tif math.Abs(manualOfficial.EstimatedCost-peak.EstimatedCost) > 1e-9 {\n\t\tt.Fatalf(\"手动官方 DeepSeek 渠道未应用峰值倍率: managed=%v manual=%v\", peak.EstimatedCost, manualOfficial.EstimatedCost)\n\t}\n\tupstream.BaseURL = \"https://relay.example/v1\"\n\trelay := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\tif math.Abs(relay.EstimatedCost-before.EstimatedCost) > 1e-9 {\n\t\tt.Fatalf(\"第三方 relay 不应应用 DeepSeek 官方倍率: before=%v relay=%v\", before.EstimatedCost, relay.EstimatedCost)\n\t}\n}\n\nfunc TestSmartRouterAppliesCanonicalBenchmarkToDomainScore(t *testing.T) {\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\tentry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"sol\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_sol\"}, \"responses\", \"gpt-5.6-sol\", nil,\n\t)\n\tapplyDomainStrength(&entry, TaskDomainReasoning)\n\n\tif math.Abs(entry.ScoringCandidate.DomainStrengthScore-0.875) > 1e-9 {\n\t\tt.Fatalf(\"DomainStrengthScore = %v, want 0.875\", entry.ScoringCandidate.DomainStrengthScore)\n\t}\n\tif entry.ScoringCandidate.DomainEvidence == nil ||\n\t\tentry.ScoringCandidate.DomainEvidence.Source != \"canonical_benchmark\" ||\n\t\tentry.ScoringCandidate.DomainEvidence.CanonicalModel != \"gpt-5.6-sol\" {\n\t\tt.Fatalf(\"DomainEvidence = %+v\", entry.ScoringCandidate.DomainEvidence)\n\t}\n\n\tscored := ScoreCandidate(entry.ScoringCandidate, ScoringContext{Weights: DefaultTaskWeights()[TaskClassWorker]})\n\tif scored.DomainEvidence == nil || scored.DomainEvidence.CanonicalCeiling != 0.875 {\n\t\tt.Fatalf(\"scored DomainEvidence = %+v\", scored.DomainEvidence)\n\t}\n}\n\nfunc TestSmartRouterPrefersEndpointDomainOverrideAndAppliesProviderFactor(t *testing.T) {\n\tprofile := &ModelProfile{\n\t\tChannelUID: \"ch_sol\",\n\t\tChannelKind: \"responses\",\n\t\tMetricsKey: \"endpoint-a\",\n\t\tModelID: \"gpt-5.6-sol\",\n\t\tModelFamily: ModelFamilyOpenAI,\n\t\tProviderQualityScore: 0.8,\n\t\tProviderQualityConfidence: 0.75,\n\t}\n\tstore := &ModelProfileStore{cache: map[string]*ModelProfile{\"sol\": profile}}\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\trouter.SetModelProfileStore(store)\n\n\tentry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"sol\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_sol\"}, \"responses\", \"gpt-5.6-sol\", nil,\n\t)\n\tapplyDomainStrength(&entry, TaskDomainReasoning)\n\tif math.Abs(entry.ScoringCandidate.DomainStrengthScore-0.74375) > 1e-9 {\n\t\tt.Fatalf(\"quality-adjusted DomainStrengthScore = %v, want 0.74375\", entry.ScoringCandidate.DomainStrengthScore)\n\t}\n\n\tprofile.TaskDomainStrengths = map[TaskDomain]float64{TaskDomainReasoning: 0.97}\n\tentry = router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"sol\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_sol\"}, \"responses\", \"gpt-5.6-sol\", nil,\n\t)\n\tapplyDomainStrength(&entry, TaskDomainReasoning)\n\tif entry.ScoringCandidate.DomainStrengthScore != 0.97 ||\n\t\tentry.ScoringCandidate.DomainEvidence.Source != \"endpoint_override\" {\n\t\tt.Fatalf(\"endpoint override evidence = %+v\", entry.ScoringCandidate.DomainEvidence)\n\t}\n}\n\nfunc TestBuildChannelEntryMergesRegistryAndEndpointCapabilities(t *testing.T) {\n\tstore, err := NewProfileStore(filepath.Join(t.TempDir(), \"profiles.db\"))\n\tif err != nil {\n\t\tt.Fatalf(\"创建 ProfileStore 失败: %v\", err)\n\t}\n\tdefer store.Close()\n\n\tprofiles := []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_registry\", ChannelUID: \"ch_registry\", ChannelKind: \"messages\",\n\t\t\tHealthState: HealthStateUnknown, QualityTier: QualityTierNormal,\n\t\t\tStabilityTier: StabilityTierNormal, SpeedTier: SpeedTierNormal, CostTier: CostTierNormal,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_profile\", ChannelUID: \"ch_profile\", ChannelKind: \"messages\",\n\t\t\tHealthState: HealthStateUnknown, QualityTier: QualityTierNormal,\n\t\t\tStabilityTier: StabilityTierNormal, SpeedTier: SpeedTierNormal, CostTier: CostTierNormal,\n\t\t\tSupportsVision: true, SupportsToolCalls: true, SupportsReasoning: true,\n\t\t},\n\t}\n\tfor _, profile := range profiles {\n\t\tif err := store.Upsert(profile); err != nil {\n\t\t\tt.Fatalf(\"写入 profile 失败: %v\", err)\n\t\t}\n\t}\n\n\trouter := NewSmartRouter(store, nil, nil, nil)\n\tregistryEntry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"registry\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_registry\"}, \"messages\", \"glm-5.2\", nil,\n\t)\n\tif !registryEntry.SupportsToolCalls || !registryEntry.SupportsReasoning {\n\t\tt.Fatalf(\"空画像不应抹掉注册表能力: %+v\", registryEntry)\n\t}\n\tif reasons := routingHardConstraintReasons(&RequestProfile{ToolUseNeed: true, ReasoningNeed: true}, ®istryEntry); len(reasons) != 0 {\n\t\tt.Fatalf(\"注册表已知能力不应触发硬约束: %v\", reasons)\n\t}\n\n\tprofileEntry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 1, Name: \"profile\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_profile\"}, \"messages\", \"unknown-model\", nil,\n\t)\n\tif !profileEntry.SupportsVision || !profileEntry.SupportsToolCalls || !profileEntry.SupportsReasoning {\n\t\tt.Fatalf(\"画像正向能力未合并: %+v\", profileEntry)\n\t}\n}\n"}, "808c426f1eec39764bda6b27b04fb06db40479f7": {"t": 1785759335.2791626, "n": 6589, "h": "8a46378ffa75ea3f", "c": "package autopilot\n\nimport (\n\t\"math\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/BenedictKing/ccx/internal/scheduler\"\n)\n\nfunc TestBuildChannelEntryUsesRegistryCapabilities(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tmodel string\n\t\tupstream config.UpstreamConfig\n\t\twantVision bool\n\t\twantTools bool\n\t\twantReasoning bool\n\t}{\n\t\t{\n\t\t\tname: \"多模态模型使用内置能力\", model: \"mimo-v2.5\",\n\t\t\twantVision: true, wantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"文本模型不会误报视觉\", model: \"mimo-v2.5-pro\",\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"NoVision 强制覆盖注册表\", model: \"mimo-v2.5\",\n\t\t\tupstream: config.UpstreamConfig{NoVision: true},\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"映射后的 NoVisionModels 强制覆盖注册表\", model: \"alias-model\",\n\t\t\tupstream: config.UpstreamConfig{\n\t\t\t\tModelMapping: map[string]string{\"alias-model\": \"mimo-v2.5\"},\n\t\t\t\tNoVisionModels: []string{\"mimo-v2.5\"},\n\t\t\t},\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"渠道能力覆盖可提供正向能力\", model: \"custom-model\",\n\t\t\tupstream: config.UpstreamConfig{ModelCapabilities: map[string]config.UpstreamModelCapability{\n\t\t\t\t\"custom-model\": {\n\t\t\t\t\tThinkingMode: \"thinking\",\n\t\t\t\t\tCapabilities: map[string]bool{\"vision\": true, \"toolCalls\": true},\n\t\t\t\t},\n\t\t\t}},\n\t\t\twantVision: true, wantTools: true, wantReasoning: true,\n\t\t},\n\t}\n\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tupstream := tt.upstream\n\t\t\tupstream.ChannelUID = \"ch_registry\"\n\t\t\tentry := router.buildChannelEntry(\n\t\t\t\tscheduler.ChannelInfo{Index: 0, Name: \"registry\", Status: \"active\"},\n\t\t\t\t&upstream, \"messages\", tt.model, nil,\n\t\t\t)\n\t\t\tif entry.SupportsVision != tt.wantVision || entry.SupportsToolCalls != tt.wantTools ||\n\t\t\t\tentry.SupportsReasoning != tt.wantReasoning {\n\t\t\t\tt.Fatalf(\"capabilities vision=%v tools=%v reasoning=%v, want %v/%v/%v\",\n\t\t\t\t\tentry.SupportsVision, entry.SupportsToolCalls, entry.SupportsReasoning,\n\t\t\t\t\ttt.wantVision, tt.wantTools, tt.wantReasoning)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSmartRouterAppliesCanonicalBenchmarkToDomainScore(t *testing.T) {\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\tentry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"sol\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_sol\"}, \"responses\", \"gpt-5.6-sol\", nil,\n\t)\n\tapplyDomainStrength(&entry, TaskDomainReasoning)\n\n\tif math.Abs(entry.ScoringCandidate.DomainStrengthScore-0.875) > 1e-9 {\n\t\tt.Fatalf(\"DomainStrengthScore = %v, want 0.875\", entry.ScoringCandidate.DomainStrengthScore)\n\t}\n\tif entry.ScoringCandidate.DomainEvidence == nil ||\n\t\tentry.ScoringCandidate.DomainEvidence.Source != \"canonical_benchmark\" ||\n\t\tentry.ScoringCandidate.DomainEvidence.CanonicalModel != \"gpt-5.6-sol\" {\n\t\tt.Fatalf(\"DomainEvidence = %+v\", entry.ScoringCandidate.DomainEvidence)\n\t}\n\n\tscored := ScoreCandidate(entry.ScoringCandidate, ScoringContext{Weights: DefaultTaskWeights()[TaskClassWorker]})\n\tif scored.DomainEvidence == nil || scored.DomainEvidence.CanonicalCeiling != 0.875 {\n\t\tt.Fatalf(\"scored DomainEvidence = %+v\", scored.DomainEvidence)\n\t}\n}\n\nfunc TestSmartRouterPrefersEndpointDomainOverrideAndAppliesProviderFactor(t *testing.T) {\n\tprofile := &ModelProfile{\n\t\tChannelUID: \"ch_sol\",\n\t\tChannelKind: \"responses\",\n\t\tMetricsKey: \"endpoint-a\",\n\t\tModelID: \"gpt-5.6-sol\",\n\t\tModelFamily: ModelFamilyOpenAI,\n\t\tProviderQualityScore: 0.8,\n\t\tProviderQualityConfidence: 0.75,\n\t}\n\tstore := &ModelProfileStore{cache: map[string]*ModelProfile{\"sol\": profile}}\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\trouter.SetModelProfileStore(store)\n\n\tentry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"sol\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_sol\"}, \"responses\", \"gpt-5.6-sol\", nil,\n\t)\n\tapplyDomainStrength(&entry, TaskDomainReasoning)\n\tif math.Abs(entry.ScoringCandidate.DomainStrengthScore-0.74375) > 1e-9 {\n\t\tt.Fatalf(\"quality-adjusted DomainStrengthScore = %v, want 0.74375\", entry.ScoringCandidate.DomainStrengthScore)\n\t}\n\n\tprofile.TaskDomainStrengths = map[TaskDomain]float64{TaskDomainReasoning: 0.97}\n\tentry = router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"sol\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_sol\"}, \"responses\", \"gpt-5.6-sol\", nil,\n\t)\n\tapplyDomainStrength(&entry, TaskDomainReasoning)\n\tif entry.ScoringCandidate.DomainStrengthScore != 0.97 ||\n\t\tentry.ScoringCandidate.DomainEvidence.Source != \"endpoint_override\" {\n\t\tt.Fatalf(\"endpoint override evidence = %+v\", entry.ScoringCandidate.DomainEvidence)\n\t}\n}\n\nfunc TestBuildChannelEntryMergesRegistryAndEndpointCapabilities(t *testing.T) {\n\tstore, err := NewProfileStore(filepath.Join(t.TempDir(), \"profiles.db\"))\n\tif err != nil {\n\t\tt.Fatalf(\"创建 ProfileStore 失败: %v\", err)\n\t}\n\tdefer store.Close()\n\n\tprofiles := []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_registry\", ChannelUID: \"ch_registry\", ChannelKind: \"messages\",\n\t\t\tHealthState: HealthStateUnknown, QualityTier: QualityTierNormal,\n\t\t\tStabilityTier: StabilityTierNormal, SpeedTier: SpeedTierNormal, CostTier: CostTierNormal,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_profile\", ChannelUID: \"ch_profile\", ChannelKind: \"messages\",\n\t\t\tHealthState: HealthStateUnknown, QualityTier: QualityTierNormal,\n\t\t\tStabilityTier: StabilityTierNormal, SpeedTier: SpeedTierNormal, CostTier: CostTierNormal,\n\t\t\tSupportsVision: true, SupportsToolCalls: true, SupportsReasoning: true,\n\t\t},\n\t}\n\tfor _, profile := range profiles {\n\t\tif err := store.Upsert(profile); err != nil {\n\t\t\tt.Fatalf(\"写入 profile 失败: %v\", err)\n\t\t}\n\t}\n\n\trouter := NewSmartRouter(store, nil, nil, nil)\n\tregistryEntry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"registry\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_registry\"}, \"messages\", \"glm-5.2\", nil,\n\t)\n\tif !registryEntry.SupportsToolCalls || !registryEntry.SupportsReasoning {\n\t\tt.Fatalf(\"空画像不应抹掉注册表能力: %+v\", registryEntry)\n\t}\n\tif reasons := routingHardConstraintReasons(&RequestProfile{ToolUseNeed: true, ReasoningNeed: true}, ®istryEntry); len(reasons) != 0 {\n\t\tt.Fatalf(\"注册表已知能力不应触发硬约束: %v\", reasons)\n\t}\n\n\tprofileEntry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 1, Name: \"profile\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_profile\"}, \"messages\", \"unknown-model\", nil,\n\t)\n\tif !profileEntry.SupportsVision || !profileEntry.SupportsToolCalls || !profileEntry.SupportsReasoning {\n\t\tt.Fatalf(\"画像正向能力未合并: %+v\", profileEntry)\n\t}\n}\n"}, "cf5359eb528b541e7a2bb58f439ac17a4cb0da45": {"t": 1785759336.9609919, "n": 3971, "h": "d7efc404a0d6848c", "c": "package autopilot\n\nimport (\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/BenedictKing/ccx/internal/scheduler\"\n)\n\nfunc TestBuildChannelEntryUsesRegistryCapabilities(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tmodel string\n\t\tupstream config.UpstreamConfig\n\t\twantVision bool\n\t\twantTools bool\n\t\twantReasoning bool\n\t}{\n\t\t{\n\t\t\tname: \"多模态模型使用内置能力\", model: \"mimo-v2.5\",\n\t\t\twantVision: true, wantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"文本模型不会误报视觉\", model: \"mimo-v2.5-pro\",\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"NoVision 强制覆盖注册表\", model: \"mimo-v2.5\",\n\t\t\tupstream: config.UpstreamConfig{NoVision: true},\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"映射后的 NoVisionModels 强制覆盖注册表\", model: \"alias-model\",\n\t\t\tupstream: config.UpstreamConfig{\n\t\t\t\tModelMapping: map[string]string{\"alias-model\": \"mimo-v2.5\"},\n\t\t\t\tNoVisionModels: []string{\"mimo-v2.5\"},\n\t\t\t},\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"渠道能力覆盖可提供正向能力\", model: \"custom-model\",\n\t\t\tupstream: config.UpstreamConfig{ModelCapabilities: map[string]config.UpstreamModelCapability{\n\t\t\t\t\"custom-model\": {\n\t\t\t\t\tThinkingMode: \"thinking\",\n\t\t\t\t\tCapabilities: map[string]bool{\"vision\": true, \"toolCalls\": true},\n\t\t\t\t},\n\t\t\t}},\n\t\t\twantVision: true, wantTools: true, wantReasoning: true,\n\t\t},\n\t}\n\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tupstream := tt.upstream\n\t\t\tupstream.ChannelUID = \"ch_registry\"\n\t\t\tentry := router.buildChannelEntry(\n\t\t\t\tscheduler.ChannelInfo{Index: 0, Name: \"registry\", Status: \"active\"},\n\t\t\t\t&upstream, \"messages\", tt.model, nil,\n\t\t\t)\n\t\t\tif entry.SupportsVision != tt.wantVision || entry.SupportsToolCalls != tt.wantTools ||\n\t\t\t\tentry.SupportsReasoning != tt.wantReasoning {\n\t\t\t\tt.Fatalf(\"capabilities vision=%v tools=%v reasoning=%v, want %v/%v/%v\",\n\t\t\t\t\tentry.SupportsVision, entry.SupportsToolCalls, entry.SupportsReasoning,\n\t\t\t\t\ttt.wantVision, tt.wantTools, tt.wantReasoning)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBuildChannelEntryMergesRegistryAndEndpointCapabilities(t *testing.T) {\n\tstore, err := NewProfileStore(filepath.Join(t.TempDir(), \"profiles.db\"))\n\tif err != nil {\n\t\tt.Fatalf(\"创建 ProfileStore 失败: %v\", err)\n\t}\n\tdefer store.Close()\n\n\tprofiles := []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_registry\", ChannelUID: \"ch_registry\", ChannelKind: \"messages\",\n\t\t\tHealthState: HealthStateUnknown, QualityTier: QualityTierNormal,\n\t\t\tStabilityTier: StabilityTierNormal, SpeedTier: SpeedTierNormal, CostTier: CostTierNormal,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_profile\", ChannelUID: \"ch_profile\", ChannelKind: \"messages\",\n\t\t\tHealthState: HealthStateUnknown, QualityTier: QualityTierNormal,\n\t\t\tStabilityTier: StabilityTierNormal, SpeedTier: SpeedTierNormal, CostTier: CostTierNormal,\n\t\t\tSupportsVision: true, SupportsToolCalls: true, SupportsReasoning: true,\n\t\t},\n\t}\n\tfor _, profile := range profiles {\n\t\tif err := store.Upsert(profile); err != nil {\n\t\t\tt.Fatalf(\"写入 profile 失败: %v\", err)\n\t\t}\n\t}\n\n\trouter := NewSmartRouter(store, nil, nil, nil)\n\tregistryEntry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"registry\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_registry\"}, \"messages\", \"glm-5.2\", nil,\n\t)\n\tif !registryEntry.SupportsToolCalls || !registryEntry.SupportsReasoning {\n\t\tt.Fatalf(\"空画像不应抹掉注册表能力: %+v\", registryEntry)\n\t}\n\tif reasons := routingHardConstraintReasons(&RequestProfile{ToolUseNeed: true, ReasoningNeed: true}, ®istryEntry); len(reasons) != 0 {\n\t\tt.Fatalf(\"注册表已知能力不应触发硬约束: %v\", reasons)\n\t}\n\n\tprofileEntry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 1, Name: \"profile\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_profile\"}, \"messages\", \"unknown-model\", nil,\n\t)\n\tif !profileEntry.SupportsVision || !profileEntry.SupportsToolCalls || !profileEntry.SupportsReasoning {\n\t\tt.Fatalf(\"画像正向能力未合并: %+v\", profileEntry)\n\t}\n}\n"}, "71b842e3e8300d0b9329446af03a39c498580b55": {"t": 1786067572.142185, "n": 9970, "h": "fb78b88d30413d2a", "c": "package autopilot\n\nimport (\n\t\"math\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/BenedictKing/ccx/internal/errutil\"\n\t\"github.com/BenedictKing/ccx/internal/scheduler\"\n)\n\nfunc TestBuildChannelEntryUsesRegistryCapabilities(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tmodel string\n\t\tupstream config.UpstreamConfig\n\t\twantVision bool\n\t\twantTools bool\n\t\twantReasoning bool\n\t}{\n\t\t{\n\t\t\tname: \"多模态模型使用内置能力\", model: \"mimo-v2.5\",\n\t\t\twantVision: true, wantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"文本模型不会误报视觉\", model: \"mimo-v2.5-pro\",\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"NoVision 强制覆盖注册表\", model: \"mimo-v2.5\",\n\t\t\tupstream: config.UpstreamConfig{NoVision: true},\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"映射后的 NoVisionModels 强制覆盖注册表\", model: \"alias-model\",\n\t\t\tupstream: config.UpstreamConfig{\n\t\t\t\tModelMapping: map[string]string{\"alias-model\": \"mimo-v2.5\"},\n\t\t\t\tNoVisionModels: []string{\"mimo-v2.5\"},\n\t\t\t},\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"渠道能力覆盖可提供正向能力\", model: \"custom-model\",\n\t\t\tupstream: config.UpstreamConfig{ModelCapabilities: map[string]config.UpstreamModelCapability{\n\t\t\t\t\"custom-model\": {\n\t\t\t\t\tThinkingMode: \"thinking\",\n\t\t\t\t\tCapabilities: map[string]bool{\"vision\": true, \"toolCalls\": true},\n\t\t\t\t},\n\t\t\t}},\n\t\t\twantVision: true, wantTools: true, wantReasoning: true,\n\t\t},\n\t}\n\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tupstream := tt.upstream\n\t\t\tupstream.ChannelUID = \"ch_registry\"\n\t\t\tentry := router.buildChannelEntry(\n\t\t\t\tscheduler.ChannelInfo{Index: 0, Name: \"registry\", Status: \"active\"},\n\t\t\t\t&upstream, \"messages\", tt.model, nil,\n\t\t\t)\n\t\t\tif entry.SupportsVision != tt.wantVision || entry.SupportsToolCalls != tt.wantTools ||\n\t\t\t\tentry.SupportsReasoning != tt.wantReasoning {\n\t\t\t\tt.Fatalf(\"capabilities vision=%v tools=%v reasoning=%v, want %v/%v/%v\",\n\t\t\t\t\tentry.SupportsVision, entry.SupportsToolCalls, entry.SupportsReasoning,\n\t\t\t\t\ttt.wantVision, tt.wantTools, tt.wantReasoning)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBuildChannelEntryUsesMappedModelQualityTier(t *testing.T) {\n\tmodelStore := newModelPreviewStore(t,\n\t\tModelProfile{\n\t\t\tChannelUID: \"ch_kimi\", ChannelKind: \"messages\", MetricsKey: \"k3-endpoint\",\n\t\t\tModelID: \"k3\", ModelFamily: ModelFamilyUnknown, QualityTier: QualityTierLow,\n\t\t\tSource: \"auto_discovery\",\n\t\t\tProbeSuccess: true,\n\t\t},\n\t\tModelProfile{\n\t\t\tChannelUID: \"ch_kimi\", ChannelKind: \"messages\", MetricsKey: \"coding-endpoint\",\n\t\t\tModelID: \"kimi-for-coding\", ModelFamily: ModelFamilyKimi, QualityTier: QualityTierNormal,\n\t\t\tSource: \"auto_discovery\",\n\t\t\tProbeSuccess: true,\n\t\t},\n\t)\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\trouter.SetModelProfileStore(modelStore)\n\tupstream := &config.UpstreamConfig{ChannelUID: \"ch_kimi\"}\n\tchannel := scheduler.ChannelInfo{Index: 0, Name: \"kimi\", Status: \"active\"}\n\n\tk3Entry := router.buildChannelEntry(channel, upstream, \"messages\", \"k3\", nil)\n\tcodingEntry := router.buildChannelEntry(channel, upstream, \"messages\", \"kimi-for-coding\", nil)\n\tif k3Entry.ScoringCandidate.QualityTier != QualityTierPremium {\n\t\tt.Fatalf(\"K3 quality tier = %q, want premium\", k3Entry.ScoringCandidate.QualityTier)\n\t}\n\tif codingEntry.ScoringCandidate.QualityTier != QualityTierHigh {\n\t\tt.Fatalf(\"kimi-for-coding quality tier = %q, want high\", codingEntry.ScoringCandidate.QualityTier)\n\t}\n}\n\nfunc TestBuildChannelEntryAppliesProviderTimePricingAfterActivation(t *testing.T) {\n\tmanager, cleanup := createTestConfigManager(t, config.Config{AutopilotRouting: config.DefaultAutopilotRoutingConfig()})\n\tdefer cleanup()\n\trouter := NewSmartRouter(nil, nil, nil, manager)\n\tupstream := &config.UpstreamConfig{ChannelUID: \"ch_deepseek\", ProviderID: \"deepseek\"}\n\tchannel := scheduler.ChannelInfo{Index: 0, Name: \"deepseek\", Status: \"active\"}\n\n\trouter.now = func() time.Time {\n\t\treturn time.Date(2026, 7, 19, 10, 0, 0, 0, time.FixedZone(\"CST\", 8*60*60))\n\t}\n\tbefore := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\trouter.now = func() time.Time {\n\t\treturn time.Date(2026, 7, 20, 10, 0, 0, 0, time.FixedZone(\"CST\", 8*60*60))\n\t}\n\tpeak := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\tif before.EstimatedCost <= 0 || math.Abs(peak.EstimatedCost-before.EstimatedCost*2) > 1e-9 {\n\t\tt.Fatalf(\"estimated cost before=%v peak=%v\", before.EstimatedCost, peak.EstimatedCost)\n\t}\n\n\tupstream.ProviderID = \"\"\n\tupstream.BaseURL = \"https://api.deepseek.com/anthropic\"\n\tmanualOfficial := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\tif math.Abs(manualOfficial.EstimatedCost-peak.EstimatedCost) > 1e-9 {\n\t\tt.Fatalf(\"手动官方 DeepSeek 渠道未应用峰值倍率: managed=%v manual=%v\", peak.EstimatedCost, manualOfficial.EstimatedCost)\n\t}\n\tupstream.BaseURL = \"https://relay.example/v1\"\n\trelay := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\tif math.Abs(relay.EstimatedCost-before.EstimatedCost) > 1e-9 {\n\t\tt.Fatalf(\"第三方 relay 不应应用 DeepSeek 官方倍率: before=%v relay=%v\", before.EstimatedCost, relay.EstimatedCost)\n\t}\n}\n\nfunc TestSmartRouterAppliesCanonicalBenchmarkToDomainScore(t *testing.T) {\n\t// 期望值由当前 registry 推导,避免评测数据刷新导致断言失效。\n\twant := canonicalDomainCeiling(t, \"gpt-5.6-sol\", TaskDomainReasoning)\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\tentry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"sol\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_sol\"}, \"responses\", \"gpt-5.6-sol\", nil,\n\t)\n\tapplyDomainStrength(&entry, TaskDomainReasoning)\n\n\tif math.Abs(entry.ScoringCandidate.DomainStrengthScore-want) > 1e-9 {\n\t\tt.Fatalf(\"DomainStrengthScore = %v, want %v\", entry.ScoringCandidate.DomainStrengthScore, want)\n\t}\n\tif entry.ScoringCandidate.DomainEvidence == nil ||\n\t\tentry.ScoringCandidate.DomainEvidence.Source != \"canonical_benchmark\" ||\n\t\tentry.ScoringCandidate.DomainEvidence.CanonicalModel != \"gpt-5.6-sol\" {\n\t\tt.Fatalf(\"DomainEvidence = %+v\", entry.ScoringCandidate.DomainEvidence)\n\t}\n\n\tscored := ScoreCandidate(entry.ScoringCandidate, ScoringContext{Weights: DefaultTaskWeights()[TaskClassWorker]})\n\tif scored.DomainEvidence == nil || math.Abs(scored.DomainEvidence.CanonicalCeiling-want) > 1e-9 {\n\t\tt.Fatalf(\"scored DomainEvidence = %+v\", scored.DomainEvidence)\n\t}\n}\n\nfunc TestSmartRouterPrefersEndpointDomainOverrideAndAppliesProviderFactor(t *testing.T) {\n\tprofile := &ModelProfile{\n\t\tChannelUID: \"ch_sol\",\n\t\tChannelKind: \"responses\",\n\t\tMetricsKey: \"endpoint-a\",\n\t\tModelID: \"gpt-5.6-sol\",\n\t\tModelFamily: ModelFamilyOpenAI,\n\t\tProviderQualityScore: 0.8,\n\t\tProviderQualityConfidence: 0.75,\n\t}\n\tstore := &ModelProfileStore{cache: map[string]*ModelProfile{\"sol\": profile}}\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\trouter.SetModelProfileStore(store)\n\n\tentry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"sol\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_sol\"}, \"responses\", \"gpt-5.6-sol\", nil,\n\t)\n\tapplyDomainStrength(&entry, TaskDomainReasoning)\n\t// factor = 1 - 0.75 * (1 - 0.8) = 0.85;上界随 registry 刷新动态推导。\n\tif want := canonicalDomainCeiling(t, \"gpt-5.6-sol\", TaskDomainReasoning) * 0.85; math.Abs(entry.ScoringCandidate.DomainStrengthScore-want) > 1e-9 {\n\t\tt.Fatalf(\"quality-adjusted DomainStrengthScore = %v, want %v\", entry.ScoringCandidate.DomainStrengthScore, want)\n\t}\n\n\tprofile.TaskDomainStrengths = map[TaskDomain]float64{TaskDomainReasoning: 0.97}\n\tentry = router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"sol\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_sol\"}, \"responses\", \"gpt-5.6-sol\", nil,\n\t)\n\tapplyDomainStrength(&entry, TaskDomainReasoning)\n\tif entry.ScoringCandidate.DomainStrengthScore != 0.97 ||\n\t\tentry.ScoringCandidate.DomainEvidence.Source != \"endpoint_override\" {\n\t\tt.Fatalf(\"endpoint override evidence = %+v\", entry.ScoringCandidate.DomainEvidence)\n\t}\n}\n\nfunc TestBuildChannelEntryMergesRegistryAndEndpointCapabilities(t *testing.T) {\n\tstore, err := NewProfileStore(filepath.Join(t.TempDir(), \"profiles.db\"))\n\tif err != nil {\n\t\tt.Fatalf(\"创建 ProfileStore 失败: %v\", err)\n\t}\n\tdefer errutil.IgnoreDeferred(store.Close)\n\n\tprofiles := []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_registry\", ChannelUID: \"ch_registry\", ChannelKind: \"messages\",\n\t\t\tHealthState: HealthStateUnknown, QualityTier: QualityTierNormal,\n\t\t\tStabilityTier: StabilityTierNormal, SpeedTier: SpeedTierNormal, CostTier: CostTierNormal,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_profile\", ChannelUID: \"ch_profile\", ChannelKind: \"messages\",\n\t\t\tHealthState: HealthStateUnknown, QualityTier: QualityTierNormal,\n\t\t\tStabilityTier: StabilityTierNormal, SpeedTier: SpeedTierNormal, CostTier: CostTierNormal,\n\t\t\tSupportsVision: true, SupportsToolCalls: true, SupportsReasoning: true,\n\t\t},\n\t}\n\tfor _, profile := range profiles {\n\t\tif err := store.Upsert(profile); err != nil {\n\t\t\tt.Fatalf(\"写入 profile 失败: %v\", err)\n\t\t}\n\t}\n\n\trouter := NewSmartRouter(store, nil, nil, nil)\n\tregistryEntry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"registry\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_registry\"}, \"messages\", \"glm-5.2\", nil,\n\t)\n\tif !registryEntry.SupportsToolCalls || !registryEntry.SupportsReasoning {\n\t\tt.Fatalf(\"空画像不应抹掉注册表能力: %+v\", registryEntry)\n\t}\n\tif reasons := routingHardConstraintReasons(&RequestProfile{ToolUseNeed: true, ReasoningNeed: true}, ®istryEntry); len(reasons) != 0 {\n\t\tt.Fatalf(\"注册表已知能力不应触发硬约束: %v\", reasons)\n\t}\n\n\tprofileEntry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 1, Name: \"profile\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_profile\"}, \"messages\", \"unknown-model\", nil,\n\t)\n\tif !profileEntry.SupportsVision || !profileEntry.SupportsToolCalls || !profileEntry.SupportsReasoning {\n\t\tt.Fatalf(\"画像正向能力未合并: %+v\", profileEntry)\n\t}\n}\n"}, "8cb1f08da90e4858885f246ddadd488390fdba08": {"t": 1786068371.141465, "n": 9970, "h": "fb78b88d30413d2a", "c": "package autopilot\n\nimport (\n\t\"math\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/BenedictKing/ccx/internal/errutil\"\n\t\"github.com/BenedictKing/ccx/internal/scheduler\"\n)\n\nfunc TestBuildChannelEntryUsesRegistryCapabilities(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tmodel string\n\t\tupstream config.UpstreamConfig\n\t\twantVision bool\n\t\twantTools bool\n\t\twantReasoning bool\n\t}{\n\t\t{\n\t\t\tname: \"多模态模型使用内置能力\", model: \"mimo-v2.5\",\n\t\t\twantVision: true, wantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"文本模型不会误报视觉\", model: \"mimo-v2.5-pro\",\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"NoVision 强制覆盖注册表\", model: \"mimo-v2.5\",\n\t\t\tupstream: config.UpstreamConfig{NoVision: true},\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"映射后的 NoVisionModels 强制覆盖注册表\", model: \"alias-model\",\n\t\t\tupstream: config.UpstreamConfig{\n\t\t\t\tModelMapping: map[string]string{\"alias-model\": \"mimo-v2.5\"},\n\t\t\t\tNoVisionModels: []string{\"mimo-v2.5\"},\n\t\t\t},\n\t\t\twantTools: true, wantReasoning: true,\n\t\t},\n\t\t{\n\t\t\tname: \"渠道能力覆盖可提供正向能力\", model: \"custom-model\",\n\t\t\tupstream: config.UpstreamConfig{ModelCapabilities: map[string]config.UpstreamModelCapability{\n\t\t\t\t\"custom-model\": {\n\t\t\t\t\tThinkingMode: \"thinking\",\n\t\t\t\t\tCapabilities: map[string]bool{\"vision\": true, \"toolCalls\": true},\n\t\t\t\t},\n\t\t\t}},\n\t\t\twantVision: true, wantTools: true, wantReasoning: true,\n\t\t},\n\t}\n\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tupstream := tt.upstream\n\t\t\tupstream.ChannelUID = \"ch_registry\"\n\t\t\tentry := router.buildChannelEntry(\n\t\t\t\tscheduler.ChannelInfo{Index: 0, Name: \"registry\", Status: \"active\"},\n\t\t\t\t&upstream, \"messages\", tt.model, nil,\n\t\t\t)\n\t\t\tif entry.SupportsVision != tt.wantVision || entry.SupportsToolCalls != tt.wantTools ||\n\t\t\t\tentry.SupportsReasoning != tt.wantReasoning {\n\t\t\t\tt.Fatalf(\"capabilities vision=%v tools=%v reasoning=%v, want %v/%v/%v\",\n\t\t\t\t\tentry.SupportsVision, entry.SupportsToolCalls, entry.SupportsReasoning,\n\t\t\t\t\ttt.wantVision, tt.wantTools, tt.wantReasoning)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBuildChannelEntryUsesMappedModelQualityTier(t *testing.T) {\n\tmodelStore := newModelPreviewStore(t,\n\t\tModelProfile{\n\t\t\tChannelUID: \"ch_kimi\", ChannelKind: \"messages\", MetricsKey: \"k3-endpoint\",\n\t\t\tModelID: \"k3\", ModelFamily: ModelFamilyUnknown, QualityTier: QualityTierLow,\n\t\t\tSource: \"auto_discovery\",\n\t\t\tProbeSuccess: true,\n\t\t},\n\t\tModelProfile{\n\t\t\tChannelUID: \"ch_kimi\", ChannelKind: \"messages\", MetricsKey: \"coding-endpoint\",\n\t\t\tModelID: \"kimi-for-coding\", ModelFamily: ModelFamilyKimi, QualityTier: QualityTierNormal,\n\t\t\tSource: \"auto_discovery\",\n\t\t\tProbeSuccess: true,\n\t\t},\n\t)\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\trouter.SetModelProfileStore(modelStore)\n\tupstream := &config.UpstreamConfig{ChannelUID: \"ch_kimi\"}\n\tchannel := scheduler.ChannelInfo{Index: 0, Name: \"kimi\", Status: \"active\"}\n\n\tk3Entry := router.buildChannelEntry(channel, upstream, \"messages\", \"k3\", nil)\n\tcodingEntry := router.buildChannelEntry(channel, upstream, \"messages\", \"kimi-for-coding\", nil)\n\tif k3Entry.ScoringCandidate.QualityTier != QualityTierPremium {\n\t\tt.Fatalf(\"K3 quality tier = %q, want premium\", k3Entry.ScoringCandidate.QualityTier)\n\t}\n\tif codingEntry.ScoringCandidate.QualityTier != QualityTierHigh {\n\t\tt.Fatalf(\"kimi-for-coding quality tier = %q, want high\", codingEntry.ScoringCandidate.QualityTier)\n\t}\n}\n\nfunc TestBuildChannelEntryAppliesProviderTimePricingAfterActivation(t *testing.T) {\n\tmanager, cleanup := createTestConfigManager(t, config.Config{AutopilotRouting: config.DefaultAutopilotRoutingConfig()})\n\tdefer cleanup()\n\trouter := NewSmartRouter(nil, nil, nil, manager)\n\tupstream := &config.UpstreamConfig{ChannelUID: \"ch_deepseek\", ProviderID: \"deepseek\"}\n\tchannel := scheduler.ChannelInfo{Index: 0, Name: \"deepseek\", Status: \"active\"}\n\n\trouter.now = func() time.Time {\n\t\treturn time.Date(2026, 7, 19, 10, 0, 0, 0, time.FixedZone(\"CST\", 8*60*60))\n\t}\n\tbefore := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\trouter.now = func() time.Time {\n\t\treturn time.Date(2026, 7, 20, 10, 0, 0, 0, time.FixedZone(\"CST\", 8*60*60))\n\t}\n\tpeak := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\tif before.EstimatedCost <= 0 || math.Abs(peak.EstimatedCost-before.EstimatedCost*2) > 1e-9 {\n\t\tt.Fatalf(\"estimated cost before=%v peak=%v\", before.EstimatedCost, peak.EstimatedCost)\n\t}\n\n\tupstream.ProviderID = \"\"\n\tupstream.BaseURL = \"https://api.deepseek.com/anthropic\"\n\tmanualOfficial := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\tif math.Abs(manualOfficial.EstimatedCost-peak.EstimatedCost) > 1e-9 {\n\t\tt.Fatalf(\"手动官方 DeepSeek 渠道未应用峰值倍率: managed=%v manual=%v\", peak.EstimatedCost, manualOfficial.EstimatedCost)\n\t}\n\tupstream.BaseURL = \"https://relay.example/v1\"\n\trelay := router.buildChannelEntry(channel, upstream, \"messages\", \"deepseek-v4-pro\", nil)\n\tif math.Abs(relay.EstimatedCost-before.EstimatedCost) > 1e-9 {\n\t\tt.Fatalf(\"第三方 relay 不应应用 DeepSeek 官方倍率: before=%v relay=%v\", before.EstimatedCost, relay.EstimatedCost)\n\t}\n}\n\nfunc TestSmartRouterAppliesCanonicalBenchmarkToDomainScore(t *testing.T) {\n\t// 期望值由当前 registry 推导,避免评测数据刷新导致断言失效。\n\twant := canonicalDomainCeiling(t, \"gpt-5.6-sol\", TaskDomainReasoning)\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\tentry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"sol\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_sol\"}, \"responses\", \"gpt-5.6-sol\", nil,\n\t)\n\tapplyDomainStrength(&entry, TaskDomainReasoning)\n\n\tif math.Abs(entry.ScoringCandidate.DomainStrengthScore-want) > 1e-9 {\n\t\tt.Fatalf(\"DomainStrengthScore = %v, want %v\", entry.ScoringCandidate.DomainStrengthScore, want)\n\t}\n\tif entry.ScoringCandidate.DomainEvidence == nil ||\n\t\tentry.ScoringCandidate.DomainEvidence.Source != \"canonical_benchmark\" ||\n\t\tentry.ScoringCandidate.DomainEvidence.CanonicalModel != \"gpt-5.6-sol\" {\n\t\tt.Fatalf(\"DomainEvidence = %+v\", entry.ScoringCandidate.DomainEvidence)\n\t}\n\n\tscored := ScoreCandidate(entry.ScoringCandidate, ScoringContext{Weights: DefaultTaskWeights()[TaskClassWorker]})\n\tif scored.DomainEvidence == nil || math.Abs(scored.DomainEvidence.CanonicalCeiling-want) > 1e-9 {\n\t\tt.Fatalf(\"scored DomainEvidence = %+v\", scored.DomainEvidence)\n\t}\n}\n\nfunc TestSmartRouterPrefersEndpointDomainOverrideAndAppliesProviderFactor(t *testing.T) {\n\tprofile := &ModelProfile{\n\t\tChannelUID: \"ch_sol\",\n\t\tChannelKind: \"responses\",\n\t\tMetricsKey: \"endpoint-a\",\n\t\tModelID: \"gpt-5.6-sol\",\n\t\tModelFamily: ModelFamilyOpenAI,\n\t\tProviderQualityScore: 0.8,\n\t\tProviderQualityConfidence: 0.75,\n\t}\n\tstore := &ModelProfileStore{cache: map[string]*ModelProfile{\"sol\": profile}}\n\trouter := NewSmartRouter(nil, nil, nil, nil)\n\trouter.SetModelProfileStore(store)\n\n\tentry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"sol\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_sol\"}, \"responses\", \"gpt-5.6-sol\", nil,\n\t)\n\tapplyDomainStrength(&entry, TaskDomainReasoning)\n\t// factor = 1 - 0.75 * (1 - 0.8) = 0.85;上界随 registry 刷新动态推导。\n\tif want := canonicalDomainCeiling(t, \"gpt-5.6-sol\", TaskDomainReasoning) * 0.85; math.Abs(entry.ScoringCandidate.DomainStrengthScore-want) > 1e-9 {\n\t\tt.Fatalf(\"quality-adjusted DomainStrengthScore = %v, want %v\", entry.ScoringCandidate.DomainStrengthScore, want)\n\t}\n\n\tprofile.TaskDomainStrengths = map[TaskDomain]float64{TaskDomainReasoning: 0.97}\n\tentry = router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"sol\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_sol\"}, \"responses\", \"gpt-5.6-sol\", nil,\n\t)\n\tapplyDomainStrength(&entry, TaskDomainReasoning)\n\tif entry.ScoringCandidate.DomainStrengthScore != 0.97 ||\n\t\tentry.ScoringCandidate.DomainEvidence.Source != \"endpoint_override\" {\n\t\tt.Fatalf(\"endpoint override evidence = %+v\", entry.ScoringCandidate.DomainEvidence)\n\t}\n}\n\nfunc TestBuildChannelEntryMergesRegistryAndEndpointCapabilities(t *testing.T) {\n\tstore, err := NewProfileStore(filepath.Join(t.TempDir(), \"profiles.db\"))\n\tif err != nil {\n\t\tt.Fatalf(\"创建 ProfileStore 失败: %v\", err)\n\t}\n\tdefer errutil.IgnoreDeferred(store.Close)\n\n\tprofiles := []*KeyEndpointProfile{\n\t\t{\n\t\t\tEndpointUID: \"ep_registry\", ChannelUID: \"ch_registry\", ChannelKind: \"messages\",\n\t\t\tHealthState: HealthStateUnknown, QualityTier: QualityTierNormal,\n\t\t\tStabilityTier: StabilityTierNormal, SpeedTier: SpeedTierNormal, CostTier: CostTierNormal,\n\t\t},\n\t\t{\n\t\t\tEndpointUID: \"ep_profile\", ChannelUID: \"ch_profile\", ChannelKind: \"messages\",\n\t\t\tHealthState: HealthStateUnknown, QualityTier: QualityTierNormal,\n\t\t\tStabilityTier: StabilityTierNormal, SpeedTier: SpeedTierNormal, CostTier: CostTierNormal,\n\t\t\tSupportsVision: true, SupportsToolCalls: true, SupportsReasoning: true,\n\t\t},\n\t}\n\tfor _, profile := range profiles {\n\t\tif err := store.Upsert(profile); err != nil {\n\t\t\tt.Fatalf(\"写入 profile 失败: %v\", err)\n\t\t}\n\t}\n\n\trouter := NewSmartRouter(store, nil, nil, nil)\n\tregistryEntry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 0, Name: \"registry\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_registry\"}, \"messages\", \"glm-5.2\", nil,\n\t)\n\tif !registryEntry.SupportsToolCalls || !registryEntry.SupportsReasoning {\n\t\tt.Fatalf(\"空画像不应抹掉注册表能力: %+v\", registryEntry)\n\t}\n\tif reasons := routingHardConstraintReasons(&RequestProfile{ToolUseNeed: true, ReasoningNeed: true}, ®istryEntry); len(reasons) != 0 {\n\t\tt.Fatalf(\"注册表已知能力不应触发硬约束: %v\", reasons)\n\t}\n\n\tprofileEntry := router.buildChannelEntry(\n\t\tscheduler.ChannelInfo{Index: 1, Name: \"profile\", Status: \"active\"},\n\t\t&config.UpstreamConfig{ChannelUID: \"ch_profile\"}, \"messages\", \"unknown-model\", nil,\n\t)\n\tif !profileEntry.SupportsVision || !profileEntry.SupportsToolCalls || !profileEntry.SupportsReasoning {\n\t\tt.Fatalf(\"画像正向能力未合并: %+v\", profileEntry)\n\t}\n}\n"}}, "backend-go/internal/autopilot/deepseek_account_test.go": {"48488333f5455eef9b0999cee1eaf5bb63dd23d6": {"t": 1785746077.1924887, "n": 3852, "h": "c5f2a9adc0c59f3b", "c": "package autopilot\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n)\n\nfunc TestDeepSeekClientFetchBalance(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet || r.URL.Path != \"/user/balance\" {\n\t\t\tt.Fatalf(\"request = %s %s\", r.Method, r.URL.Path)\n\t\t}\n\t\tif got := r.Header.Get(\"Authorization\"); got != \"Bearer sk-test\" {\n\t\t\tt.Fatalf(\"Authorization = %q\", got)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_, _ = w.Write([]byte(`{\"is_available\":true,\"balance_infos\":[{\"currency\":\"cny\",\"total_balance\":\"110.00\",\"granted_balance\":\"10.00\",\"topped_up_balance\":\"100.00\"}]}`))\n\t}))\n\tdefer server.Close()\n\n\tclient := NewDeepSeekClient(server.Client())\n\tclient.BaseURL = server.URL\n\tbalance, err := client.FetchBalance(t.Context(), \"sk-test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !balance.IsAvailable || len(balance.BalanceInfos) != 1 || balance.BalanceInfos[0].Currency != \"CNY\" || balance.BalanceInfos[0].TotalBalance != \"110.00\" {\n\t\tt.Fatalf(\"balance = %+v\", balance)\n\t}\n}\n\nfunc TestHandleDeepSeekBalanceMasksCredentialsAndKeepsPartialErrors(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tswitch r.Header.Get(\"Authorization\") {\n\t\tcase \"Bearer sk-good-secret\":\n\t\t\t_, _ = w.Write([]byte(`{\"is_available\":true,\"balance_infos\":[{\"currency\":\"USD\",\"total_balance\":\"2.50\",\"granted_balance\":\"0.50\",\"topped_up_balance\":\"2.00\"}]}`))\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"invalid key\"}}`))\n\t\t}\n\t}))\n\tdefer server.Close()\n\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_ds\",\"providerId\":\"deepseek\",\"name\":\"deepseek\",\"credentials\":[\n {\"credentialUid\":\"cred_good\",\"apiKey\":\"sk-good-secret\"},\n {\"credentialUid\":\"cred_bad\",\"apiKey\":\"sk-bad-secret\"}\n ]}],\n \"upstream\":[{\"accountUid\":\"acct_ds\",\"channelUid\":\"ch_ds\",\"providerId\":\"deepseek\",\"name\":\"deepseek-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.deepseek.com/anthropic\",\"apiKeys\":[\"sk-good-secret\",\"sk-bad-secret\"],\"apiKeyConfigs\":[\n {\"credentialUid\":\"cred_good\",\"key\":\"sk-good-secret\",\"baseUrl\":\"https://api.deepseek.com/anthropic\"},\n {\"credentialUid\":\"cred_bad\",\"key\":\"sk-bad-secret\",\"baseUrl\":\"https://api.deepseek.com/anthropic\"}\n ],\"autoManaged\":true,\"status\":\"active\"}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\tclient := NewDeepSeekClient(server.Client())\n\tclient.BaseURL = server.URL\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, DeepSeekClient: client})\n\trecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, \"/api/accounts/acct_ds/deepseek-balance\", nil))\n\tif recorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", recorder.Code, recorder.Body.String())\n\t}\n\tif strings.Contains(recorder.Body.String(), \"sk-good-secret\") || strings.Contains(recorder.Body.String(), \"sk-bad-secret\") {\n\t\tt.Fatalf(\"响应泄漏明文 Key: %s\", recorder.Body.String())\n\t}\n\tvar response struct {\n\t\tBalances []managedDeepSeekCredentialBalance `json:\"balances\"`\n\t}\n\tif err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Balances) != 2 || !response.Balances[0].IsAvailable || response.Balances[1].Error == \"\" {\n\t\tt.Fatalf(\"balances=%+v\", response.Balances)\n\t}\n}\n"}, "5d2ea67008b8062dd3b4f7104a16827e50c393b3": {"t": 1785759312.1464477, "n": 3852, "h": "c5f2a9adc0c59f3b", "c": "package autopilot\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n)\n\nfunc TestDeepSeekClientFetchBalance(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet || r.URL.Path != \"/user/balance\" {\n\t\t\tt.Fatalf(\"request = %s %s\", r.Method, r.URL.Path)\n\t\t}\n\t\tif got := r.Header.Get(\"Authorization\"); got != \"Bearer sk-test\" {\n\t\t\tt.Fatalf(\"Authorization = %q\", got)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_, _ = w.Write([]byte(`{\"is_available\":true,\"balance_infos\":[{\"currency\":\"cny\",\"total_balance\":\"110.00\",\"granted_balance\":\"10.00\",\"topped_up_balance\":\"100.00\"}]}`))\n\t}))\n\tdefer server.Close()\n\n\tclient := NewDeepSeekClient(server.Client())\n\tclient.BaseURL = server.URL\n\tbalance, err := client.FetchBalance(t.Context(), \"sk-test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !balance.IsAvailable || len(balance.BalanceInfos) != 1 || balance.BalanceInfos[0].Currency != \"CNY\" || balance.BalanceInfos[0].TotalBalance != \"110.00\" {\n\t\tt.Fatalf(\"balance = %+v\", balance)\n\t}\n}\n\nfunc TestHandleDeepSeekBalanceMasksCredentialsAndKeepsPartialErrors(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tswitch r.Header.Get(\"Authorization\") {\n\t\tcase \"Bearer sk-good-secret\":\n\t\t\t_, _ = w.Write([]byte(`{\"is_available\":true,\"balance_infos\":[{\"currency\":\"USD\",\"total_balance\":\"2.50\",\"granted_balance\":\"0.50\",\"topped_up_balance\":\"2.00\"}]}`))\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"invalid key\"}}`))\n\t\t}\n\t}))\n\tdefer server.Close()\n\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_ds\",\"providerId\":\"deepseek\",\"name\":\"deepseek\",\"credentials\":[\n {\"credentialUid\":\"cred_good\",\"apiKey\":\"sk-good-secret\"},\n {\"credentialUid\":\"cred_bad\",\"apiKey\":\"sk-bad-secret\"}\n ]}],\n \"upstream\":[{\"accountUid\":\"acct_ds\",\"channelUid\":\"ch_ds\",\"providerId\":\"deepseek\",\"name\":\"deepseek-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.deepseek.com/anthropic\",\"apiKeys\":[\"sk-good-secret\",\"sk-bad-secret\"],\"apiKeyConfigs\":[\n {\"credentialUid\":\"cred_good\",\"key\":\"sk-good-secret\",\"baseUrl\":\"https://api.deepseek.com/anthropic\"},\n {\"credentialUid\":\"cred_bad\",\"key\":\"sk-bad-secret\",\"baseUrl\":\"https://api.deepseek.com/anthropic\"}\n ],\"autoManaged\":true,\"status\":\"active\"}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\tclient := NewDeepSeekClient(server.Client())\n\tclient.BaseURL = server.URL\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, DeepSeekClient: client})\n\trecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, \"/api/accounts/acct_ds/deepseek-balance\", nil))\n\tif recorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", recorder.Code, recorder.Body.String())\n\t}\n\tif strings.Contains(recorder.Body.String(), \"sk-good-secret\") || strings.Contains(recorder.Body.String(), \"sk-bad-secret\") {\n\t\tt.Fatalf(\"响应泄漏明文 Key: %s\", recorder.Body.String())\n\t}\n\tvar response struct {\n\t\tBalances []managedDeepSeekCredentialBalance `json:\"balances\"`\n\t}\n\tif err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Balances) != 2 || !response.Balances[0].IsAvailable || response.Balances[1].Error == \"\" {\n\t\tt.Fatalf(\"balances=%+v\", response.Balances)\n\t}\n}\n"}, "71b842e3e8300d0b9329446af03a39c498580b55": {"t": 1786067576.3773544, "n": 3852, "h": "c5f2a9adc0c59f3b", "c": "package autopilot\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n)\n\nfunc TestDeepSeekClientFetchBalance(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet || r.URL.Path != \"/user/balance\" {\n\t\t\tt.Fatalf(\"request = %s %s\", r.Method, r.URL.Path)\n\t\t}\n\t\tif got := r.Header.Get(\"Authorization\"); got != \"Bearer sk-test\" {\n\t\t\tt.Fatalf(\"Authorization = %q\", got)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_, _ = w.Write([]byte(`{\"is_available\":true,\"balance_infos\":[{\"currency\":\"cny\",\"total_balance\":\"110.00\",\"granted_balance\":\"10.00\",\"topped_up_balance\":\"100.00\"}]}`))\n\t}))\n\tdefer server.Close()\n\n\tclient := NewDeepSeekClient(server.Client())\n\tclient.BaseURL = server.URL\n\tbalance, err := client.FetchBalance(t.Context(), \"sk-test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !balance.IsAvailable || len(balance.BalanceInfos) != 1 || balance.BalanceInfos[0].Currency != \"CNY\" || balance.BalanceInfos[0].TotalBalance != \"110.00\" {\n\t\tt.Fatalf(\"balance = %+v\", balance)\n\t}\n}\n\nfunc TestHandleDeepSeekBalanceMasksCredentialsAndKeepsPartialErrors(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tswitch r.Header.Get(\"Authorization\") {\n\t\tcase \"Bearer sk-good-secret\":\n\t\t\t_, _ = w.Write([]byte(`{\"is_available\":true,\"balance_infos\":[{\"currency\":\"USD\",\"total_balance\":\"2.50\",\"granted_balance\":\"0.50\",\"topped_up_balance\":\"2.00\"}]}`))\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"invalid key\"}}`))\n\t\t}\n\t}))\n\tdefer server.Close()\n\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_ds\",\"providerId\":\"deepseek\",\"name\":\"deepseek\",\"credentials\":[\n {\"credentialUid\":\"cred_good\",\"apiKey\":\"sk-good-secret\"},\n {\"credentialUid\":\"cred_bad\",\"apiKey\":\"sk-bad-secret\"}\n ]}],\n \"upstream\":[{\"accountUid\":\"acct_ds\",\"channelUid\":\"ch_ds\",\"providerId\":\"deepseek\",\"name\":\"deepseek-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.deepseek.com/anthropic\",\"apiKeys\":[\"sk-good-secret\",\"sk-bad-secret\"],\"apiKeyConfigs\":[\n {\"credentialUid\":\"cred_good\",\"key\":\"sk-good-secret\",\"baseUrl\":\"https://api.deepseek.com/anthropic\"},\n {\"credentialUid\":\"cred_bad\",\"key\":\"sk-bad-secret\",\"baseUrl\":\"https://api.deepseek.com/anthropic\"}\n ],\"autoManaged\":true,\"status\":\"active\"}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\tclient := NewDeepSeekClient(server.Client())\n\tclient.BaseURL = server.URL\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, DeepSeekClient: client})\n\trecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, \"/api/accounts/acct_ds/deepseek-balance\", nil))\n\tif recorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", recorder.Code, recorder.Body.String())\n\t}\n\tif strings.Contains(recorder.Body.String(), \"sk-good-secret\") || strings.Contains(recorder.Body.String(), \"sk-bad-secret\") {\n\t\tt.Fatalf(\"响应泄漏明文 Key: %s\", recorder.Body.String())\n\t}\n\tvar response struct {\n\t\tBalances []managedDeepSeekCredentialBalance `json:\"balances\"`\n\t}\n\tif err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Balances) != 2 || !response.Balances[0].IsAvailable || response.Balances[1].Error == \"\" {\n\t\tt.Fatalf(\"balances=%+v\", response.Balances)\n\t}\n}\n"}, "8cb1f08da90e4858885f246ddadd488390fdba08": {"t": 1786068373.3918357, "n": 3852, "h": "c5f2a9adc0c59f3b", "c": "package autopilot\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n)\n\nfunc TestDeepSeekClientFetchBalance(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet || r.URL.Path != \"/user/balance\" {\n\t\t\tt.Fatalf(\"request = %s %s\", r.Method, r.URL.Path)\n\t\t}\n\t\tif got := r.Header.Get(\"Authorization\"); got != \"Bearer sk-test\" {\n\t\t\tt.Fatalf(\"Authorization = %q\", got)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_, _ = w.Write([]byte(`{\"is_available\":true,\"balance_infos\":[{\"currency\":\"cny\",\"total_balance\":\"110.00\",\"granted_balance\":\"10.00\",\"topped_up_balance\":\"100.00\"}]}`))\n\t}))\n\tdefer server.Close()\n\n\tclient := NewDeepSeekClient(server.Client())\n\tclient.BaseURL = server.URL\n\tbalance, err := client.FetchBalance(t.Context(), \"sk-test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !balance.IsAvailable || len(balance.BalanceInfos) != 1 || balance.BalanceInfos[0].Currency != \"CNY\" || balance.BalanceInfos[0].TotalBalance != \"110.00\" {\n\t\tt.Fatalf(\"balance = %+v\", balance)\n\t}\n}\n\nfunc TestHandleDeepSeekBalanceMasksCredentialsAndKeepsPartialErrors(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tswitch r.Header.Get(\"Authorization\") {\n\t\tcase \"Bearer sk-good-secret\":\n\t\t\t_, _ = w.Write([]byte(`{\"is_available\":true,\"balance_infos\":[{\"currency\":\"USD\",\"total_balance\":\"2.50\",\"granted_balance\":\"0.50\",\"topped_up_balance\":\"2.00\"}]}`))\n\t\tdefault:\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\t_, _ = w.Write([]byte(`{\"error\":{\"message\":\"invalid key\"}}`))\n\t\t}\n\t}))\n\tdefer server.Close()\n\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tdata := `{\n \"managedAccounts\":[{\"accountUid\":\"acct_ds\",\"providerId\":\"deepseek\",\"name\":\"deepseek\",\"credentials\":[\n {\"credentialUid\":\"cred_good\",\"apiKey\":\"sk-good-secret\"},\n {\"credentialUid\":\"cred_bad\",\"apiKey\":\"sk-bad-secret\"}\n ]}],\n \"upstream\":[{\"accountUid\":\"acct_ds\",\"channelUid\":\"ch_ds\",\"providerId\":\"deepseek\",\"name\":\"deepseek-claude\",\"serviceType\":\"claude\",\"baseUrl\":\"https://api.deepseek.com/anthropic\",\"apiKeys\":[\"sk-good-secret\",\"sk-bad-secret\"],\"apiKeyConfigs\":[\n {\"credentialUid\":\"cred_good\",\"key\":\"sk-good-secret\",\"baseUrl\":\"https://api.deepseek.com/anthropic\"},\n {\"credentialUid\":\"cred_bad\",\"key\":\"sk-bad-secret\",\"baseUrl\":\"https://api.deepseek.com/anthropic\"}\n ],\"autoManaged\":true,\"status\":\"active\"}],\n \"chatUpstream\":[],\"responsesUpstream\":[],\"geminiUpstream\":[],\"imagesUpstream\":[],\"vectorsUpstream\":[]\n}`\n\tif err := os.WriteFile(configPath, []byte(data), 0600); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmanager, err := config.NewConfigManager(configPath, filepath.Join(dir, \"backups\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { _ = manager.Close() })\n\tclient := NewDeepSeekClient(server.Client())\n\tclient.BaseURL = server.URL\n\trouter := setupAutoManagedRouter(&AutoManagedDeps{CfgManager: manager, DeepSeekClient: client})\n\trecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, \"/api/accounts/acct_ds/deepseek-balance\", nil))\n\tif recorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d body=%s\", recorder.Code, recorder.Body.String())\n\t}\n\tif strings.Contains(recorder.Body.String(), \"sk-good-secret\") || strings.Contains(recorder.Body.String(), \"sk-bad-secret\") {\n\t\tt.Fatalf(\"响应泄漏明文 Key: %s\", recorder.Body.String())\n\t}\n\tvar response struct {\n\t\tBalances []managedDeepSeekCredentialBalance `json:\"balances\"`\n\t}\n\tif err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(response.Balances) != 2 || !response.Balances[0].IsAvailable || response.Balances[1].Error == \"\" {\n\t\tt.Fatalf(\"balances=%+v\", response.Balances)\n\t}\n}\n"}}, "backend-go/internal/config/builtin_models_manifest.go": {"48488333f5455eef9b0999cee1eaf5bb63dd23d6": {"t": 1785746083.996394, "n": 11921, "h": "10bedd911c7ff8be", "c": "package config\n\nimport (\n\t\"net/url\"\n\t\"strings\"\n\n\t\"github.com/BenedictKing/ccx/internal/presetstore\"\n)\n\n// BuiltinModelsManifest 内置模型清单条目。\n// 对部分官方订阅入口(如 Claude OAuth 订阅、Codex plan 入口),\n// 上游 models 接口可能不存在(404)或返回错误清单。\n// 命中时直接使用 ModelIDs 作为该入口的可用模型列表,跳过上游探测。\ntype BuiltinModelsManifest struct {\n\t// BaseURLPattern 精确 host 或 host+path 前缀匹配,\n\t// 如 \"api.anthropic.com\" 或 \"api.anthropic.com/v1\"。\n\tBaseURLPattern string `json:\"baseUrlPattern\"`\n\n\t// ServiceType 运行时上游服务类型;claude 会归一为 messages,OpenAI Chat 兼容入口用 openai。\n\tServiceType string `json:\"serviceType\"`\n\n\t// PlanHint 订阅类型提示,仅用于文档和日志,不影响匹配逻辑。\n\tPlanHint string `json:\"planHint,omitempty\"`\n\n\t// ModelsURL 覆盖默认从 baseURL 拼接的模型列表地址。\n\t// 用于协议入口与官方模型列表入口不在同一路径下的 provider。\n\tModelsURL string `json:\"modelsUrl,omitempty\"`\n\n\t// ModelIDs 该入口实际可用的模型 ID 清单。\n\tModelIDs []string `json:\"modelIds\"`\n\n\t// ExcludeModelPatterns 从上游 /v1/models 返回中剔除的模型 ID 正则。\n\tExcludeModelPatterns []string `json:\"excludeModelPatterns,omitempty\"`\n\n\t// DisableProbe 为 true 时 Discovery 流程跳过 GET /v1/models,\n\t// 直接用 ModelIDs 生成 ModelProfile[]。\n\tDisableProbe bool `json:\"disableProbe\"`\n}\n\n// builtinModelsManifests 内置模型清单。\n// 当前仅收录从仓库代码/模板/测试中能确认的官方入口。\n// 新增条目须附注证据来源,不臆造 baseURL。\nvar builtinModelsManifests = []BuiltinModelsManifest{\n\t// Anthropic 官方 API 入口(来源:buildClaudeCompatibleModelsURLs 测试、\n\t// expectedRequestUrls 测试、前端 locale placeholder 确认 api.anthropic.com)。\n\t// 该入口的 /v1/models 正常可用,DisableProbe=false;\n\t// 当上游探测失败时回退使用此清单。\n\t{\n\t\tBaseURLPattern: \"api.anthropic.com\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"anthropic_api\",\n\t\tModelIDs: []string{\n\t\t\t\"claude-fable-5\",\n\t\t\t\"claude-mythos-5\",\n\t\t\t\"claude-opus-4-8\",\n\t\t\t\"claude-opus-4-7\",\n\t\t\t\"claude-opus-4-6\",\n\t\t\t\"claude-sonnet-5\",\n\t\t\t\"claude-sonnet-4-6\",\n\t\t\t\"claude-sonnet-4-5\",\n\t\t\t\"claude-opus-4-5\",\n\t\t\t\"claude-haiku-4-5\",\n\t\t},\n\t\tDisableProbe: false,\n\t},\n\t// DeepSeek 官方兼容入口(来源:provider_templates.go 与 DeepSeek API 文档)。\n\t// 两种协议统一通过官方 GET https://api.deepseek.com/models 获取模型清单;\n\t// 请求入口仍分别使用 /anthropic 与 OpenAI 兼容根地址。\n\t{\n\t\tBaseURLPattern: \"api.deepseek.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"deepseek_anthropic\",\n\t\tModelsURL: \"https://api.deepseek.com/models\",\n\t\tModelIDs: deepseekModelIDs(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"api.deepseek.com\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"deepseek_openai\",\n\t\tModelsURL: \"https://api.deepseek.com/models\",\n\t\tModelIDs: deepseekModelIDs(),\n\t\tDisableProbe: false,\n\t},\n\t// 小米 MiMo Anthropic 兼容入口(来源:provider_templates.go 与 docs/providers/mimo.md)。\n\t// Anthropic 协议入口可用性通过 /v1/messages 验证;/v1/models 不作为能力判定依据。\n\t{\n\t\tBaseURLPattern: \"api.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_payg_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-cn.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_cn_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_sgp_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-ams.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_ams_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t// 小米 MiMo OpenAI Chat 兼容入口(来源:provider_templates.go 与 docs/providers/mimo.md)。\n\t// /v1/models 可用于发现;返回 ASR/TTS 等非文本模型时通过 ExcludeModelPatterns 过滤。\n\t{\n\t\tBaseURLPattern: \"api.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_payg_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-cn.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_token_plan_cn_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-sgp.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_token_plan_sgp_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-ams.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_token_plan_ams_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t// Kimi Code 官方订阅入口(来源:Kimi Code 模型配置与概览文档)。\n\t// /coding/v1/models 会按 API Key 返回当前会员档位可用的模型;\n\t// ModelIDs 仅作为 models 端点不可用时的静态回退,只保留所有套餐都可用的基础模型。\n\t{\n\t\tBaseURLPattern: \"api.kimi.com/coding\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"kimi_code_anthropic\",\n\t\tModelsURL: \"https://api.kimi.com/coding/v1/models\",\n\t\tModelIDs: kimiCodeModelIDs(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"api.kimi.com/coding/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"kimi_code_openai\",\n\t\tModelsURL: \"https://api.kimi.com/coding/v1/models\",\n\t\tModelIDs: kimiCodeModelIDs(),\n\t\tDisableProbe: false,\n\t},\n\t// 火山方舟 Agent/Coding Plan 套餐入口(来源:provider_templates.go volcengine 模板)。\n\t// 套餐模型发现依赖火山云管控面签名接口,普通推理 Key 无法通过 /v1/models 探测;\n\t// 未绑定 Access Key 时用此清单兜底,让渠道立即可用。DisableProbe=true。\n\t// BaseURLPattern 用 host+path 前缀(不含版本段),以同时匹配 openai 入口的 /v3 后缀。\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/plan\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"volcengine_plan_anthropic\",\n\t\tModelIDs: volcengineAgentPlanModelIDs(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/coding\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"volcengine_coding_anthropic\",\n\t\tModelIDs: volcengineCodingPlanModelIDs(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/plan\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"volcengine_plan_openai\",\n\t\tModelIDs: volcengineAgentPlanModelIDs(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/coding\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"volcengine_coding_openai\",\n\t\tModelIDs: volcengineCodingPlanModelIDs(),\n\t\tDisableProbe: true,\n\t},\n}\n\nfunc mimoModelIDs() []string {\n\treturn []string{\n\t\t\"mimo-v2.5-pro\",\n\t\t\"mimo-v2.5\",\n\t}\n}\n\nfunc kimiCodeModelIDs() []string {\n\treturn []string{\n\t\t\"kimi-for-coding\",\n\t}\n}\n\nfunc deepseekModelIDs() []string {\n\treturn []string{\n\t\t\"deepseek-v4-pro\",\n\t\t\"deepseek-v4-flash\",\n\t}\n}\n\n// volcengineAgentPlanModelIDs 火山方舟 Agent Plan(/api/plan) 入口的兜底文本模型清单。\n// 当用户未绑定火山云 Access Key(无法调用管控面模型发现接口)时,\n// 用此清单让渠道立即可用;绑定 Access Key 后由 FetchModels 覆盖为真实清单。\n// 清单来源:火山方舟 Agent Plan 套餐概览(2026-07),与 Coding Plan 略有差异。\nfunc volcengineAgentPlanModelIDs() []string {\n\treturn []string{\n\t\t\"doubao-seed-2.0-code\",\n\t\t\"doubao-seed-2.0-pro\",\n\t\t\"doubao-seed-2.0-lite\",\n\t\t\"doubao-seed-2.0-mini\",\n\t\t\"minimax-m2.7\",\n\t\t\"minimax-m3\",\n\t\t\"glm-5.2\",\n\t\t\"glm-latest\",\n\t\t\"deepseek-v4-flash\",\n\t\t\"deepseek-v4-pro\",\n\t\t\"kimi-k3\",\n\t\t\"kimi-k2.6\",\n\t\t\"kimi-k2.7-code\",\n\t}\n}\n\n// volcengineCodingPlanModelIDs 火山方舟 Coding Plan(/api/coding) 入口的兜底文本模型清单。\n// 与 Agent Plan 差异:Coding Plan 独有 doubao-seed-code,不含 doubao-seed-2.0-mini/glm-latest。\n// 清单来源:火山方舟 Coding Plan 套餐概览(2026-07)。\nfunc volcengineCodingPlanModelIDs() []string {\n\treturn []string{\n\t\t\"doubao-seed-2.0-code\",\n\t\t\"doubao-seed-2.0-pro\",\n\t\t\"doubao-seed-2.0-lite\",\n\t\t\"doubao-seed-code\",\n\t\t\"minimax-m2.7\",\n\t\t\"minimax-m3\",\n\t\t\"glm-5.2\",\n\t\t\"deepseek-v4-flash\",\n\t\t\"deepseek-v4-pro\",\n\t\t\"kimi-k2.6\",\n\t\t\"kimi-k2.7-code\",\n\t}\n}\n\nfunc mimoExcludeModelPatterns() []string {\n\treturn []string{\n\t\t`^mimo-v2\\.5-(?:asr|tts(?:-.+)?)$`,\n\t}\n}\n\n// LookupBuiltinManifest 根据上游 baseURL 和 serviceType 查找匹配的内置清单。\n// 匹配规则:BaseURLPattern 作为 host 或 host+path 前缀匹配。\n// 优先精确 host 匹配,其次 host+path 前缀匹配(最长前缀优先)。\n// 返回匹配的清单和 true;未命中返回零值和 false。\nfunc LookupBuiltinManifest(baseURL string, serviceType string) (BuiltinModelsManifest, bool) {\n\tnormalized := normalizeBaseURLForManifest(baseURL)\n\tif normalized == \"\" {\n\t\treturn BuiltinModelsManifest{}, false\n\t}\n\n\tif manifest, found := lookupBuiltinManifestIn(runtimeBuiltinModelsManifests(), normalized, serviceType); found {\n\t\treturn manifest, true\n\t}\n\treturn lookupBuiltinManifestIn(builtinModelsManifests, normalized, serviceType)\n}\n\n// ResolveBuiltinModelsURL 返回清单声明的模型列表地址。\n// 为避免把 API Key 发送到第三方主机,覆盖地址必须使用 HTTPS 且与渠道 baseURL 同主机。\nfunc ResolveBuiltinModelsURL(baseURL string, serviceType string) (string, bool) {\n\tmanifest, ok := LookupBuiltinManifest(baseURL, strings.ToLower(strings.TrimSpace(serviceType)))\n\tif !ok || strings.TrimSpace(manifest.ModelsURL) == \"\" {\n\t\treturn \"\", false\n\t}\n\tbase, baseErr := url.Parse(strings.TrimSuffix(strings.TrimSpace(baseURL), \"#\"))\n\tmodels, modelsErr := url.Parse(strings.TrimSpace(manifest.ModelsURL))\n\tif baseErr != nil || modelsErr != nil || !strings.EqualFold(models.Scheme, \"https\") ||\n\t\tbase.Hostname() == \"\" || models.User != nil || models.Fragment != \"\" || !sameURLServer(base, models) {\n\t\treturn \"\", false\n\t}\n\treturn models.String(), true\n}\n\nfunc runtimeBuiltinModelsManifests() []BuiltinModelsManifest {\n\tbundle := presetstore.Default().Get()\n\tif bundle == nil || bundle.BuiltinModelsManifests == nil || len(bundle.BuiltinModelsManifests.Manifests) == 0 {\n\t\treturn nil\n\t}\n\tmanifests := make([]BuiltinModelsManifest, 0, len(bundle.BuiltinModelsManifests.Manifests))\n\tfor _, entry := range bundle.BuiltinModelsManifests.Manifests {\n\t\tmanifests = append(manifests, BuiltinModelsManifest{\n\t\t\tBaseURLPattern: entry.BaseURLPattern,\n\t\t\tServiceType: entry.ServiceType,\n\t\t\tPlanHint: entry.PlanHint,\n\t\t\tModelsURL: entry.ModelsURL,\n\t\t\tModelIDs: append([]string(nil), entry.ModelIDs...),\n\t\t\tExcludeModelPatterns: append([]string(nil), entry.ExcludeModelPatterns...),\n\t\t\tDisableProbe: entry.DisableProbe,\n\t\t})\n\t}\n\treturn manifests\n}\n\nfunc lookupBuiltinManifestIn(manifests []BuiltinModelsManifest, normalized string, serviceType string) (BuiltinModelsManifest, bool) {\n\tif len(manifests) == 0 {\n\t\treturn BuiltinModelsManifest{}, false\n\t}\n\tvar bestMatch BuiltinModelsManifest\n\tvar bestMatchLen int\n\tfound := false\n\tfor _, manifest := range manifests {\n\t\tif manifest.ServiceType != serviceType {\n\t\t\tcontinue\n\t\t}\n\t\tpattern := strings.ToLower(manifest.BaseURLPattern)\n\t\tif !matchManifestPattern(normalized, pattern) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(pattern) > bestMatchLen {\n\t\t\tbestMatch = manifest\n\t\t\tbestMatchLen = len(pattern)\n\t\t\tfound = true\n\t\t}\n\t}\n\treturn bestMatch, found\n}\n\n// matchManifestPattern 检查 normalized baseURL 是否匹配 manifest pattern。\n// pattern 可以是纯 host(精确匹配)或 host+path 前缀匹配。\nfunc matchManifestPattern(normalized string, pattern string) bool {\n\t// 精确匹配\n\tif normalized == pattern {\n\t\treturn true\n\t}\n\t// host 精确匹配(pattern 不含路径,normalized 可能含路径)\n\tif !strings.Contains(pattern, \"/\") && strings.HasPrefix(normalized, pattern) {\n\t\trest := normalized[len(pattern):]\n\t\treturn rest == \"\" || rest[0] == '/'\n\t}\n\t// host+path 前缀匹配\n\treturn strings.HasPrefix(normalized, pattern)\n}\n\n// normalizeBaseURLForManifest 将 baseURL 规范化为 host 或 host+path 形式用于匹配。\n// 去掉 scheme(https://)、尾部 /、# 标记、版本段(/v1 等)。\nfunc normalizeBaseURLForManifest(baseURL string) string {\n\ts := strings.ToLower(strings.TrimSpace(baseURL))\n\tif s == \"\" {\n\t\treturn \"\"\n\t}\n\t// 去掉 scheme\n\tif idx := strings.Index(s, \"://\"); idx >= 0 {\n\t\ts = s[idx+3:]\n\t}\n\t// 去掉 #\n\ts = strings.TrimSuffix(s, \"#\")\n\t// 去掉尾部 /\n\ts = strings.TrimRight(s, \"/\")\n\treturn s\n}\n"}, "713d7e08c5426d026f638467afee4828d12a4481": {"t": 1785759326.0854468, "n": 11921, "h": "10bedd911c7ff8be", "c": "package config\n\nimport (\n\t\"net/url\"\n\t\"strings\"\n\n\t\"github.com/BenedictKing/ccx/internal/presetstore\"\n)\n\n// BuiltinModelsManifest 内置模型清单条目。\n// 对部分官方订阅入口(如 Claude OAuth 订阅、Codex plan 入口),\n// 上游 models 接口可能不存在(404)或返回错误清单。\n// 命中时直接使用 ModelIDs 作为该入口的可用模型列表,跳过上游探测。\ntype BuiltinModelsManifest struct {\n\t// BaseURLPattern 精确 host 或 host+path 前缀匹配,\n\t// 如 \"api.anthropic.com\" 或 \"api.anthropic.com/v1\"。\n\tBaseURLPattern string `json:\"baseUrlPattern\"`\n\n\t// ServiceType 运行时上游服务类型;claude 会归一为 messages,OpenAI Chat 兼容入口用 openai。\n\tServiceType string `json:\"serviceType\"`\n\n\t// PlanHint 订阅类型提示,仅用于文档和日志,不影响匹配逻辑。\n\tPlanHint string `json:\"planHint,omitempty\"`\n\n\t// ModelsURL 覆盖默认从 baseURL 拼接的模型列表地址。\n\t// 用于协议入口与官方模型列表入口不在同一路径下的 provider。\n\tModelsURL string `json:\"modelsUrl,omitempty\"`\n\n\t// ModelIDs 该入口实际可用的模型 ID 清单。\n\tModelIDs []string `json:\"modelIds\"`\n\n\t// ExcludeModelPatterns 从上游 /v1/models 返回中剔除的模型 ID 正则。\n\tExcludeModelPatterns []string `json:\"excludeModelPatterns,omitempty\"`\n\n\t// DisableProbe 为 true 时 Discovery 流程跳过 GET /v1/models,\n\t// 直接用 ModelIDs 生成 ModelProfile[]。\n\tDisableProbe bool `json:\"disableProbe\"`\n}\n\n// builtinModelsManifests 内置模型清单。\n// 当前仅收录从仓库代码/模板/测试中能确认的官方入口。\n// 新增条目须附注证据来源,不臆造 baseURL。\nvar builtinModelsManifests = []BuiltinModelsManifest{\n\t// Anthropic 官方 API 入口(来源:buildClaudeCompatibleModelsURLs 测试、\n\t// expectedRequestUrls 测试、前端 locale placeholder 确认 api.anthropic.com)。\n\t// 该入口的 /v1/models 正常可用,DisableProbe=false;\n\t// 当上游探测失败时回退使用此清单。\n\t{\n\t\tBaseURLPattern: \"api.anthropic.com\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"anthropic_api\",\n\t\tModelIDs: []string{\n\t\t\t\"claude-fable-5\",\n\t\t\t\"claude-mythos-5\",\n\t\t\t\"claude-opus-4-8\",\n\t\t\t\"claude-opus-4-7\",\n\t\t\t\"claude-opus-4-6\",\n\t\t\t\"claude-sonnet-5\",\n\t\t\t\"claude-sonnet-4-6\",\n\t\t\t\"claude-sonnet-4-5\",\n\t\t\t\"claude-opus-4-5\",\n\t\t\t\"claude-haiku-4-5\",\n\t\t},\n\t\tDisableProbe: false,\n\t},\n\t// DeepSeek 官方兼容入口(来源:provider_templates.go 与 DeepSeek API 文档)。\n\t// 两种协议统一通过官方 GET https://api.deepseek.com/models 获取模型清单;\n\t// 请求入口仍分别使用 /anthropic 与 OpenAI 兼容根地址。\n\t{\n\t\tBaseURLPattern: \"api.deepseek.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"deepseek_anthropic\",\n\t\tModelsURL: \"https://api.deepseek.com/models\",\n\t\tModelIDs: deepseekModelIDs(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"api.deepseek.com\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"deepseek_openai\",\n\t\tModelsURL: \"https://api.deepseek.com/models\",\n\t\tModelIDs: deepseekModelIDs(),\n\t\tDisableProbe: false,\n\t},\n\t// 小米 MiMo Anthropic 兼容入口(来源:provider_templates.go 与 docs/providers/mimo.md)。\n\t// Anthropic 协议入口可用性通过 /v1/messages 验证;/v1/models 不作为能力判定依据。\n\t{\n\t\tBaseURLPattern: \"api.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_payg_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-cn.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_cn_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_sgp_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-ams.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_ams_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t// 小米 MiMo OpenAI Chat 兼容入口(来源:provider_templates.go 与 docs/providers/mimo.md)。\n\t// /v1/models 可用于发现;返回 ASR/TTS 等非文本模型时通过 ExcludeModelPatterns 过滤。\n\t{\n\t\tBaseURLPattern: \"api.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_payg_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-cn.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_token_plan_cn_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-sgp.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_token_plan_sgp_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-ams.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_token_plan_ams_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t// Kimi Code 官方订阅入口(来源:Kimi Code 模型配置与概览文档)。\n\t// /coding/v1/models 会按 API Key 返回当前会员档位可用的模型;\n\t// ModelIDs 仅作为 models 端点不可用时的静态回退,只保留所有套餐都可用的基础模型。\n\t{\n\t\tBaseURLPattern: \"api.kimi.com/coding\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"kimi_code_anthropic\",\n\t\tModelsURL: \"https://api.kimi.com/coding/v1/models\",\n\t\tModelIDs: kimiCodeModelIDs(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"api.kimi.com/coding/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"kimi_code_openai\",\n\t\tModelsURL: \"https://api.kimi.com/coding/v1/models\",\n\t\tModelIDs: kimiCodeModelIDs(),\n\t\tDisableProbe: false,\n\t},\n\t// 火山方舟 Agent/Coding Plan 套餐入口(来源:provider_templates.go volcengine 模板)。\n\t// 套餐模型发现依赖火山云管控面签名接口,普通推理 Key 无法通过 /v1/models 探测;\n\t// 未绑定 Access Key 时用此清单兜底,让渠道立即可用。DisableProbe=true。\n\t// BaseURLPattern 用 host+path 前缀(不含版本段),以同时匹配 openai 入口的 /v3 后缀。\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/plan\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"volcengine_plan_anthropic\",\n\t\tModelIDs: volcengineAgentPlanModelIDs(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/coding\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"volcengine_coding_anthropic\",\n\t\tModelIDs: volcengineCodingPlanModelIDs(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/plan\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"volcengine_plan_openai\",\n\t\tModelIDs: volcengineAgentPlanModelIDs(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/coding\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"volcengine_coding_openai\",\n\t\tModelIDs: volcengineCodingPlanModelIDs(),\n\t\tDisableProbe: true,\n\t},\n}\n\nfunc mimoModelIDs() []string {\n\treturn []string{\n\t\t\"mimo-v2.5-pro\",\n\t\t\"mimo-v2.5\",\n\t}\n}\n\nfunc kimiCodeModelIDs() []string {\n\treturn []string{\n\t\t\"kimi-for-coding\",\n\t}\n}\n\nfunc deepseekModelIDs() []string {\n\treturn []string{\n\t\t\"deepseek-v4-pro\",\n\t\t\"deepseek-v4-flash\",\n\t}\n}\n\n// volcengineAgentPlanModelIDs 火山方舟 Agent Plan(/api/plan) 入口的兜底文本模型清单。\n// 当用户未绑定火山云 Access Key(无法调用管控面模型发现接口)时,\n// 用此清单让渠道立即可用;绑定 Access Key 后由 FetchModels 覆盖为真实清单。\n// 清单来源:火山方舟 Agent Plan 套餐概览(2026-07),与 Coding Plan 略有差异。\nfunc volcengineAgentPlanModelIDs() []string {\n\treturn []string{\n\t\t\"doubao-seed-2.0-code\",\n\t\t\"doubao-seed-2.0-pro\",\n\t\t\"doubao-seed-2.0-lite\",\n\t\t\"doubao-seed-2.0-mini\",\n\t\t\"minimax-m2.7\",\n\t\t\"minimax-m3\",\n\t\t\"glm-5.2\",\n\t\t\"glm-latest\",\n\t\t\"deepseek-v4-flash\",\n\t\t\"deepseek-v4-pro\",\n\t\t\"kimi-k3\",\n\t\t\"kimi-k2.6\",\n\t\t\"kimi-k2.7-code\",\n\t}\n}\n\n// volcengineCodingPlanModelIDs 火山方舟 Coding Plan(/api/coding) 入口的兜底文本模型清单。\n// 与 Agent Plan 差异:Coding Plan 独有 doubao-seed-code,不含 doubao-seed-2.0-mini/glm-latest。\n// 清单来源:火山方舟 Coding Plan 套餐概览(2026-07)。\nfunc volcengineCodingPlanModelIDs() []string {\n\treturn []string{\n\t\t\"doubao-seed-2.0-code\",\n\t\t\"doubao-seed-2.0-pro\",\n\t\t\"doubao-seed-2.0-lite\",\n\t\t\"doubao-seed-code\",\n\t\t\"minimax-m2.7\",\n\t\t\"minimax-m3\",\n\t\t\"glm-5.2\",\n\t\t\"deepseek-v4-flash\",\n\t\t\"deepseek-v4-pro\",\n\t\t\"kimi-k2.6\",\n\t\t\"kimi-k2.7-code\",\n\t}\n}\n\nfunc mimoExcludeModelPatterns() []string {\n\treturn []string{\n\t\t`^mimo-v2\\.5-(?:asr|tts(?:-.+)?)$`,\n\t}\n}\n\n// LookupBuiltinManifest 根据上游 baseURL 和 serviceType 查找匹配的内置清单。\n// 匹配规则:BaseURLPattern 作为 host 或 host+path 前缀匹配。\n// 优先精确 host 匹配,其次 host+path 前缀匹配(最长前缀优先)。\n// 返回匹配的清单和 true;未命中返回零值和 false。\nfunc LookupBuiltinManifest(baseURL string, serviceType string) (BuiltinModelsManifest, bool) {\n\tnormalized := normalizeBaseURLForManifest(baseURL)\n\tif normalized == \"\" {\n\t\treturn BuiltinModelsManifest{}, false\n\t}\n\n\tif manifest, found := lookupBuiltinManifestIn(runtimeBuiltinModelsManifests(), normalized, serviceType); found {\n\t\treturn manifest, true\n\t}\n\treturn lookupBuiltinManifestIn(builtinModelsManifests, normalized, serviceType)\n}\n\n// ResolveBuiltinModelsURL 返回清单声明的模型列表地址。\n// 为避免把 API Key 发送到第三方主机,覆盖地址必须使用 HTTPS 且与渠道 baseURL 同主机。\nfunc ResolveBuiltinModelsURL(baseURL string, serviceType string) (string, bool) {\n\tmanifest, ok := LookupBuiltinManifest(baseURL, strings.ToLower(strings.TrimSpace(serviceType)))\n\tif !ok || strings.TrimSpace(manifest.ModelsURL) == \"\" {\n\t\treturn \"\", false\n\t}\n\tbase, baseErr := url.Parse(strings.TrimSuffix(strings.TrimSpace(baseURL), \"#\"))\n\tmodels, modelsErr := url.Parse(strings.TrimSpace(manifest.ModelsURL))\n\tif baseErr != nil || modelsErr != nil || !strings.EqualFold(models.Scheme, \"https\") ||\n\t\tbase.Hostname() == \"\" || models.User != nil || models.Fragment != \"\" || !sameURLServer(base, models) {\n\t\treturn \"\", false\n\t}\n\treturn models.String(), true\n}\n\nfunc runtimeBuiltinModelsManifests() []BuiltinModelsManifest {\n\tbundle := presetstore.Default().Get()\n\tif bundle == nil || bundle.BuiltinModelsManifests == nil || len(bundle.BuiltinModelsManifests.Manifests) == 0 {\n\t\treturn nil\n\t}\n\tmanifests := make([]BuiltinModelsManifest, 0, len(bundle.BuiltinModelsManifests.Manifests))\n\tfor _, entry := range bundle.BuiltinModelsManifests.Manifests {\n\t\tmanifests = append(manifests, BuiltinModelsManifest{\n\t\t\tBaseURLPattern: entry.BaseURLPattern,\n\t\t\tServiceType: entry.ServiceType,\n\t\t\tPlanHint: entry.PlanHint,\n\t\t\tModelsURL: entry.ModelsURL,\n\t\t\tModelIDs: append([]string(nil), entry.ModelIDs...),\n\t\t\tExcludeModelPatterns: append([]string(nil), entry.ExcludeModelPatterns...),\n\t\t\tDisableProbe: entry.DisableProbe,\n\t\t})\n\t}\n\treturn manifests\n}\n\nfunc lookupBuiltinManifestIn(manifests []BuiltinModelsManifest, normalized string, serviceType string) (BuiltinModelsManifest, bool) {\n\tif len(manifests) == 0 {\n\t\treturn BuiltinModelsManifest{}, false\n\t}\n\tvar bestMatch BuiltinModelsManifest\n\tvar bestMatchLen int\n\tfound := false\n\tfor _, manifest := range manifests {\n\t\tif manifest.ServiceType != serviceType {\n\t\t\tcontinue\n\t\t}\n\t\tpattern := strings.ToLower(manifest.BaseURLPattern)\n\t\tif !matchManifestPattern(normalized, pattern) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(pattern) > bestMatchLen {\n\t\t\tbestMatch = manifest\n\t\t\tbestMatchLen = len(pattern)\n\t\t\tfound = true\n\t\t}\n\t}\n\treturn bestMatch, found\n}\n\n// matchManifestPattern 检查 normalized baseURL 是否匹配 manifest pattern。\n// pattern 可以是纯 host(精确匹配)或 host+path 前缀匹配。\nfunc matchManifestPattern(normalized string, pattern string) bool {\n\t// 精确匹配\n\tif normalized == pattern {\n\t\treturn true\n\t}\n\t// host 精确匹配(pattern 不含路径,normalized 可能含路径)\n\tif !strings.Contains(pattern, \"/\") && strings.HasPrefix(normalized, pattern) {\n\t\trest := normalized[len(pattern):]\n\t\treturn rest == \"\" || rest[0] == '/'\n\t}\n\t// host+path 前缀匹配\n\treturn strings.HasPrefix(normalized, pattern)\n}\n\n// normalizeBaseURLForManifest 将 baseURL 规范化为 host 或 host+path 形式用于匹配。\n// 去掉 scheme(https://)、尾部 /、# 标记、版本段(/v1 等)。\nfunc normalizeBaseURLForManifest(baseURL string) string {\n\ts := strings.ToLower(strings.TrimSpace(baseURL))\n\tif s == \"\" {\n\t\treturn \"\"\n\t}\n\t// 去掉 scheme\n\tif idx := strings.Index(s, \"://\"); idx >= 0 {\n\t\ts = s[idx+3:]\n\t}\n\t// 去掉 #\n\ts = strings.TrimSuffix(s, \"#\")\n\t// 去掉尾部 /\n\ts = strings.TrimRight(s, \"/\")\n\treturn s\n}\n"}, "be325312222deca30ed2abbef56312bca2275479": {"t": 1785759327.574211, "n": 11908, "h": "7d4887bc221dc2ab", "c": "package config\n\nimport (\n\t\"net/url\"\n\t\"strings\"\n\n\t\"github.com/BenedictKing/ccx/internal/presetstore\"\n)\n\n// BuiltinModelsManifest 内置模型清单条目。\n// 对部分官方订阅入口(如 Claude OAuth 订阅、Codex plan 入口),\n// 上游 models 接口可能不存在(404)或返回错误清单。\n// 命中时直接使用 ModelIDs 作为该入口的可用模型列表,跳过上游探测。\ntype BuiltinModelsManifest struct {\n\t// BaseURLPattern 精确 host 或 host+path 前缀匹配,\n\t// 如 \"api.anthropic.com\" 或 \"api.anthropic.com/v1\"。\n\tBaseURLPattern string `json:\"baseUrlPattern\"`\n\n\t// ServiceType 运行时上游服务类型;claude 会归一为 messages,OpenAI Chat 兼容入口用 openai。\n\tServiceType string `json:\"serviceType\"`\n\n\t// PlanHint 订阅类型提示,仅用于文档和日志,不影响匹配逻辑。\n\tPlanHint string `json:\"planHint,omitempty\"`\n\n\t// ModelsURL 覆盖默认从 baseURL 拼接的模型列表地址。\n\t// 用于协议入口与官方模型列表入口不在同一路径下的 provider。\n\tModelsURL string `json:\"modelsUrl,omitempty\"`\n\n\t// ModelIDs 该入口实际可用的模型 ID 清单。\n\tModelIDs []string `json:\"modelIds\"`\n\n\t// ExcludeModelPatterns 从上游 /v1/models 返回中剔除的模型 ID 正则。\n\tExcludeModelPatterns []string `json:\"excludeModelPatterns,omitempty\"`\n\n\t// DisableProbe 为 true 时 Discovery 流程跳过 GET /v1/models,\n\t// 直接用 ModelIDs 生成 ModelProfile[]。\n\tDisableProbe bool `json:\"disableProbe\"`\n}\n\n// builtinModelsManifests 内置模型清单。\n// 当前仅收录从仓库代码/模板/测试中能确认的官方入口。\n// 新增条目须附注证据来源,不臆造 baseURL。\nvar builtinModelsManifests = []BuiltinModelsManifest{\n\t// Anthropic 官方 API 入口(来源:buildClaudeCompatibleModelsURLs 测试、\n\t// expectedRequestUrls 测试、前端 locale placeholder 确认 api.anthropic.com)。\n\t// 该入口的 /v1/models 正常可用,DisableProbe=false;\n\t// 当上游探测失败时回退使用此清单。\n\t{\n\t\tBaseURLPattern: \"api.anthropic.com\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"anthropic_api\",\n\t\tModelIDs: []string{\n\t\t\t\"claude-fable-5\",\n\t\t\t\"claude-mythos-5\",\n\t\t\t\"claude-opus-4-8\",\n\t\t\t\"claude-opus-4-7\",\n\t\t\t\"claude-opus-4-6\",\n\t\t\t\"claude-sonnet-5\",\n\t\t\t\"claude-sonnet-4-6\",\n\t\t\t\"claude-sonnet-4-5\",\n\t\t\t\"claude-opus-4-5\",\n\t\t\t\"claude-haiku-4-5\",\n\t\t},\n\t\tDisableProbe: false,\n\t},\n\t// DeepSeek 官方兼容入口(来源:provider_templates.go 与 DeepSeek API 文档)。\n\t// 两种协议统一通过官方 GET https://api.deepseek.com/models 获取模型清单;\n\t// 请求入口仍分别使用 /anthropic 与 OpenAI 兼容根地址。\n\t{\n\t\tBaseURLPattern: \"api.deepseek.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"deepseek_anthropic\",\n\t\tModelsURL: \"https://api.deepseek.com/models\",\n\t\tModelIDs: deepseekModelIDs(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"api.deepseek.com\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"deepseek_openai\",\n\t\tModelsURL: \"https://api.deepseek.com/models\",\n\t\tModelIDs: deepseekModelIDs(),\n\t\tDisableProbe: false,\n\t},\n\t// 小米 MiMo Anthropic 兼容入口(来源:provider_templates.go 与 docs/providers/mimo.md)。\n\t// Anthropic 协议入口可用性通过 /v1/messages 验证;/v1/models 不作为能力判定依据。\n\t{\n\t\tBaseURLPattern: \"api.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_payg_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-cn.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_cn_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_sgp_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-ams.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_ams_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t// 小米 MiMo OpenAI Chat 兼容入口(来源:provider_templates.go 与 docs/providers/mimo.md)。\n\t// /v1/models 可用于发现;返回 ASR/TTS 等非文本模型时通过 ExcludeModelPatterns 过滤。\n\t{\n\t\tBaseURLPattern: \"api.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_payg_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-cn.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_token_plan_cn_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-sgp.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_token_plan_sgp_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-ams.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_token_plan_ams_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t// Kimi Code 官方订阅入口(来源:Kimi Code 模型配置与概览文档)。\n\t// /coding/v1/models 会按 API Key 返回当前会员档位可用的模型;\n\t// ModelIDs 仅作为 models 端点不可用时的静态回退,只保留所有套餐都可用的基础模型。\n\t{\n\t\tBaseURLPattern: \"api.kimi.com/coding\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"kimi_code_anthropic\",\n\t\tModelsURL: \"https://api.kimi.com/coding/v1/models\",\n\t\tModelIDs: kimiCodeModelIDs(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"api.kimi.com/coding/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"kimi_code_openai\",\n\t\tModelsURL: \"https://api.kimi.com/coding/v1/models\",\n\t\tModelIDs: kimiCodeModelIDs(),\n\t\tDisableProbe: false,\n\t},\n\t// 火山方舟 Agent/Coding Plan 套餐入口(来源:provider_templates.go volcengine 模板)。\n\t// 套餐模型发现依赖火山云管控面签名接口,普通推理 Key 无法通过 /v1/models 探测;\n\t// 未绑定 Access Key 时用此清单兜底,让渠道立即可用。DisableProbe=true。\n\t// BaseURLPattern 用 host+path 前缀(不含版本段),以同时匹配 openai 入口的 /v3 后缀。\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/plan\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"volcengine_plan_anthropic\",\n\t\tModelIDs: volcengineAgentPlanModelIDs(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/coding\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"volcengine_coding_anthropic\",\n\t\tModelIDs: volcengineCodingPlanModelIDs(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/plan\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"volcengine_plan_openai\",\n\t\tModelIDs: volcengineAgentPlanModelIDs(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/coding\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"volcengine_coding_openai\",\n\t\tModelIDs: volcengineCodingPlanModelIDs(),\n\t\tDisableProbe: true,\n\t},\n}\n\nfunc mimoModelIDs() []string {\n\treturn []string{\n\t\t\"mimo-v2.5-pro\",\n\t\t\"mimo-v2.5\",\n\t}\n}\n\nfunc kimiCodeModelIDs() []string {\n\treturn []string{\n\t\t\"kimi-for-coding\",\n\t}\n}\n\nfunc deepseekModelIDs() []string {\n\treturn []string{\n\t\t\"deepseek-v4-pro\",\n\t\t\"deepseek-v4-flash\",\n\t}\n}\n\n// volcengineAgentPlanModelIDs 火山方舟 Agent Plan(/api/plan) 入口的兜底文本模型清单。\n// 当用户未绑定火山云 Access Key(无法调用管控面模型发现接口)时,\n// 用此清单让渠道立即可用;绑定 Access Key 后由 FetchModels 覆盖为真实清单。\n// 清单来源:火山方舟 Agent Plan 套餐概览(2026-07),与 Coding Plan 略有差异。\nfunc volcengineAgentPlanModelIDs() []string {\n\treturn []string{\n\t\t\"doubao-seed-2.0-code\",\n\t\t\"doubao-seed-2.0-pro\",\n\t\t\"doubao-seed-2.0-lite\",\n\t\t\"doubao-seed-2.0-mini\",\n\t\t\"minimax-m2.7\",\n\t\t\"minimax-m3\",\n\t\t\"glm-5.2\",\n\t\t\"glm-latest\",\n\t\t\"deepseek-v4-flash\",\n\t\t\"deepseek-v4-pro\",\n\t\t\"kimi-k2.6\",\n\t\t\"kimi-k2.7-code\",\n\t}\n}\n\n// volcengineCodingPlanModelIDs 火山方舟 Coding Plan(/api/coding) 入口的兜底文本模型清单。\n// 与 Agent Plan 差异:Coding Plan 独有 doubao-seed-code,不含 doubao-seed-2.0-mini/glm-latest。\n// 清单来源:火山方舟 Coding Plan 套餐概览(2026-07)。\nfunc volcengineCodingPlanModelIDs() []string {\n\treturn []string{\n\t\t\"doubao-seed-2.0-code\",\n\t\t\"doubao-seed-2.0-pro\",\n\t\t\"doubao-seed-2.0-lite\",\n\t\t\"doubao-seed-code\",\n\t\t\"minimax-m2.7\",\n\t\t\"minimax-m3\",\n\t\t\"glm-5.2\",\n\t\t\"deepseek-v4-flash\",\n\t\t\"deepseek-v4-pro\",\n\t\t\"kimi-k2.6\",\n\t\t\"kimi-k2.7-code\",\n\t}\n}\n\nfunc mimoExcludeModelPatterns() []string {\n\treturn []string{\n\t\t`^mimo-v2\\.5-(?:asr|tts(?:-.+)?)$`,\n\t}\n}\n\n// LookupBuiltinManifest 根据上游 baseURL 和 serviceType 查找匹配的内置清单。\n// 匹配规则:BaseURLPattern 作为 host 或 host+path 前缀匹配。\n// 优先精确 host 匹配,其次 host+path 前缀匹配(最长前缀优先)。\n// 返回匹配的清单和 true;未命中返回零值和 false。\nfunc LookupBuiltinManifest(baseURL string, serviceType string) (BuiltinModelsManifest, bool) {\n\tnormalized := normalizeBaseURLForManifest(baseURL)\n\tif normalized == \"\" {\n\t\treturn BuiltinModelsManifest{}, false\n\t}\n\n\tif manifest, found := lookupBuiltinManifestIn(runtimeBuiltinModelsManifests(), normalized, serviceType); found {\n\t\treturn manifest, true\n\t}\n\treturn lookupBuiltinManifestIn(builtinModelsManifests, normalized, serviceType)\n}\n\n// ResolveBuiltinModelsURL 返回清单声明的模型列表地址。\n// 为避免把 API Key 发送到第三方主机,覆盖地址必须使用 HTTPS 且与渠道 baseURL 同主机。\nfunc ResolveBuiltinModelsURL(baseURL string, serviceType string) (string, bool) {\n\tmanifest, ok := LookupBuiltinManifest(baseURL, strings.ToLower(strings.TrimSpace(serviceType)))\n\tif !ok || strings.TrimSpace(manifest.ModelsURL) == \"\" {\n\t\treturn \"\", false\n\t}\n\tbase, baseErr := url.Parse(strings.TrimSuffix(strings.TrimSpace(baseURL), \"#\"))\n\tmodels, modelsErr := url.Parse(strings.TrimSpace(manifest.ModelsURL))\n\tif baseErr != nil || modelsErr != nil || !strings.EqualFold(models.Scheme, \"https\") ||\n\t\tbase.Hostname() == \"\" || models.User != nil || models.Fragment != \"\" || !sameURLServer(base, models) {\n\t\treturn \"\", false\n\t}\n\treturn models.String(), true\n}\n\nfunc runtimeBuiltinModelsManifests() []BuiltinModelsManifest {\n\tbundle := presetstore.Default().Get()\n\tif bundle == nil || bundle.BuiltinModelsManifests == nil || len(bundle.BuiltinModelsManifests.Manifests) == 0 {\n\t\treturn nil\n\t}\n\tmanifests := make([]BuiltinModelsManifest, 0, len(bundle.BuiltinModelsManifests.Manifests))\n\tfor _, entry := range bundle.BuiltinModelsManifests.Manifests {\n\t\tmanifests = append(manifests, BuiltinModelsManifest{\n\t\t\tBaseURLPattern: entry.BaseURLPattern,\n\t\t\tServiceType: entry.ServiceType,\n\t\t\tPlanHint: entry.PlanHint,\n\t\t\tModelsURL: entry.ModelsURL,\n\t\t\tModelIDs: append([]string(nil), entry.ModelIDs...),\n\t\t\tExcludeModelPatterns: append([]string(nil), entry.ExcludeModelPatterns...),\n\t\t\tDisableProbe: entry.DisableProbe,\n\t\t})\n\t}\n\treturn manifests\n}\n\nfunc lookupBuiltinManifestIn(manifests []BuiltinModelsManifest, normalized string, serviceType string) (BuiltinModelsManifest, bool) {\n\tif len(manifests) == 0 {\n\t\treturn BuiltinModelsManifest{}, false\n\t}\n\tvar bestMatch BuiltinModelsManifest\n\tvar bestMatchLen int\n\tfound := false\n\tfor _, manifest := range manifests {\n\t\tif manifest.ServiceType != serviceType {\n\t\t\tcontinue\n\t\t}\n\t\tpattern := strings.ToLower(manifest.BaseURLPattern)\n\t\tif !matchManifestPattern(normalized, pattern) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(pattern) > bestMatchLen {\n\t\t\tbestMatch = manifest\n\t\t\tbestMatchLen = len(pattern)\n\t\t\tfound = true\n\t\t}\n\t}\n\treturn bestMatch, found\n}\n\n// matchManifestPattern 检查 normalized baseURL 是否匹配 manifest pattern。\n// pattern 可以是纯 host(精确匹配)或 host+path 前缀匹配。\nfunc matchManifestPattern(normalized string, pattern string) bool {\n\t// 精确匹配\n\tif normalized == pattern {\n\t\treturn true\n\t}\n\t// host 精确匹配(pattern 不含路径,normalized 可能含路径)\n\tif !strings.Contains(pattern, \"/\") && strings.HasPrefix(normalized, pattern) {\n\t\trest := normalized[len(pattern):]\n\t\treturn rest == \"\" || rest[0] == '/'\n\t}\n\t// host+path 前缀匹配\n\treturn strings.HasPrefix(normalized, pattern)\n}\n\n// normalizeBaseURLForManifest 将 baseURL 规范化为 host 或 host+path 形式用于匹配。\n// 去掉 scheme(https://)、尾部 /、# 标记、版本段(/v1 等)。\nfunc normalizeBaseURLForManifest(baseURL string) string {\n\ts := strings.ToLower(strings.TrimSpace(baseURL))\n\tif s == \"\" {\n\t\treturn \"\"\n\t}\n\t// 去掉 scheme\n\tif idx := strings.Index(s, \"://\"); idx >= 0 {\n\t\ts = s[idx+3:]\n\t}\n\t// 去掉 #\n\ts = strings.TrimSuffix(s, \"#\")\n\t// 去掉尾部 /\n\ts = strings.TrimRight(s, \"/\")\n\treturn s\n}\n"}, "5d2ea67008b8062dd3b4f7104a16827e50c393b3": {"t": 1785759329.1565146, "n": 11201, "h": "475a0a837879c98c", "c": "package config\n\nimport (\n\t\"net/url\"\n\t\"strings\"\n\n\t\"github.com/BenedictKing/ccx/internal/presetstore\"\n)\n\n// BuiltinModelsManifest 内置模型清单条目。\n// 对部分官方订阅入口(如 Claude OAuth 订阅、Codex plan 入口),\n// 上游 models 接口可能不存在(404)或返回错误清单。\n// 命中时直接使用 ModelIDs 作为该入口的可用模型列表,跳过上游探测。\ntype BuiltinModelsManifest struct {\n\t// BaseURLPattern 精确 host 或 host+path 前缀匹配,\n\t// 如 \"api.anthropic.com\" 或 \"api.anthropic.com/v1\"。\n\tBaseURLPattern string `json:\"baseUrlPattern\"`\n\n\t// ServiceType 运行时上游服务类型;claude 会归一为 messages,OpenAI Chat 兼容入口用 openai。\n\tServiceType string `json:\"serviceType\"`\n\n\t// PlanHint 订阅类型提示,仅用于文档和日志,不影响匹配逻辑。\n\tPlanHint string `json:\"planHint,omitempty\"`\n\n\t// ModelsURL 覆盖默认从 baseURL 拼接的模型列表地址。\n\t// 用于协议入口与官方模型列表入口不在同一路径下的 provider。\n\tModelsURL string `json:\"modelsUrl,omitempty\"`\n\n\t// ModelIDs 该入口实际可用的模型 ID 清单。\n\tModelIDs []string `json:\"modelIds\"`\n\n\t// ExcludeModelPatterns 从上游 /v1/models 返回中剔除的模型 ID 正则。\n\tExcludeModelPatterns []string `json:\"excludeModelPatterns,omitempty\"`\n\n\t// DisableProbe 为 true 时 Discovery 流程跳过 GET /v1/models,\n\t// 直接用 ModelIDs 生成 ModelProfile[]。\n\tDisableProbe bool `json:\"disableProbe\"`\n}\n\n// builtinModelsManifests 内置模型清单。\n// 当前仅收录从仓库代码/模板/测试中能确认的官方入口。\n// 新增条目须附注证据来源,不臆造 baseURL。\nvar builtinModelsManifests = []BuiltinModelsManifest{\n\t// Anthropic 官方 API 入口(来源:buildClaudeCompatibleModelsURLs 测试、\n\t// expectedRequestUrls 测试、前端 locale placeholder 确认 api.anthropic.com)。\n\t// 该入口的 /v1/models 正常可用,DisableProbe=false;\n\t// 当上游探测失败时回退使用此清单。\n\t{\n\t\tBaseURLPattern: \"api.anthropic.com\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"anthropic_api\",\n\t\tModelIDs: []string{\n\t\t\t\"claude-fable-5\",\n\t\t\t\"claude-mythos-5\",\n\t\t\t\"claude-opus-4-8\",\n\t\t\t\"claude-opus-4-7\",\n\t\t\t\"claude-opus-4-6\",\n\t\t\t\"claude-sonnet-5\",\n\t\t\t\"claude-sonnet-4-6\",\n\t\t\t\"claude-sonnet-4-5\",\n\t\t\t\"claude-opus-4-5\",\n\t\t\t\"claude-haiku-4-5\",\n\t\t},\n\t\tDisableProbe: false,\n\t},\n\t// DeepSeek 官方兼容入口(来源:provider_templates.go 与 DeepSeek API 文档)。\n\t// 两种协议统一通过官方 GET https://api.deepseek.com/models 获取模型清单;\n\t// 请求入口仍分别使用 /anthropic 与 OpenAI 兼容根地址。\n\t{\n\t\tBaseURLPattern: \"api.deepseek.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"deepseek_anthropic\",\n\t\tModelsURL: \"https://api.deepseek.com/models\",\n\t\tModelIDs: deepseekModelIDs(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"api.deepseek.com\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"deepseek_openai\",\n\t\tModelsURL: \"https://api.deepseek.com/models\",\n\t\tModelIDs: deepseekModelIDs(),\n\t\tDisableProbe: false,\n\t},\n\t// 小米 MiMo Anthropic 兼容入口(来源:provider_templates.go 与 docs/providers/mimo.md)。\n\t// Anthropic 协议入口可用性通过 /v1/messages 验证;/v1/models 不作为能力判定依据。\n\t{\n\t\tBaseURLPattern: \"api.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_payg_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-cn.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_cn_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_sgp_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-ams.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_ams_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t// 小米 MiMo OpenAI Chat 兼容入口(来源:provider_templates.go 与 docs/providers/mimo.md)。\n\t// /v1/models 可用于发现;返回 ASR/TTS 等非文本模型时通过 ExcludeModelPatterns 过滤。\n\t{\n\t\tBaseURLPattern: \"api.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_payg_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-cn.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_token_plan_cn_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-sgp.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_token_plan_sgp_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-ams.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_token_plan_ams_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t// 火山方舟 Agent/Coding Plan 套餐入口(来源:provider_templates.go volcengine 模板)。\n\t// 套餐模型发现依赖火山云管控面签名接口,普通推理 Key 无法通过 /v1/models 探测;\n\t// 未绑定 Access Key 时用此清单兜底,让渠道立即可用。DisableProbe=true。\n\t// BaseURLPattern 用 host+path 前缀(不含版本段),以同时匹配 openai 入口的 /v3 后缀。\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/plan\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"volcengine_plan_anthropic\",\n\t\tModelIDs: volcengineAgentPlanModelIDs(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/coding\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"volcengine_coding_anthropic\",\n\t\tModelIDs: volcengineCodingPlanModelIDs(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/plan\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"volcengine_plan_openai\",\n\t\tModelIDs: volcengineAgentPlanModelIDs(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/coding\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"volcengine_coding_openai\",\n\t\tModelIDs: volcengineCodingPlanModelIDs(),\n\t\tDisableProbe: true,\n\t},\n}\n\nfunc mimoModelIDs() []string {\n\treturn []string{\n\t\t\"mimo-v2.5-pro\",\n\t\t\"mimo-v2.5\",\n\t}\n}\n\nfunc deepseekModelIDs() []string {\n\treturn []string{\n\t\t\"deepseek-v4-pro\",\n\t\t\"deepseek-v4-flash\",\n\t}\n}\n\n// volcengineAgentPlanModelIDs 火山方舟 Agent Plan(/api/plan) 入口的兜底文本模型清单。\n// 当用户未绑定火山云 Access Key(无法调用管控面模型发现接口)时,\n// 用此清单让渠道立即可用;绑定 Access Key 后由 FetchModels 覆盖为真实清单。\n// 清单来源:火山方舟 Agent Plan 套餐概览(2026-07),与 Coding Plan 略有差异。\nfunc volcengineAgentPlanModelIDs() []string {\n\treturn []string{\n\t\t\"doubao-seed-2.0-code\",\n\t\t\"doubao-seed-2.0-pro\",\n\t\t\"doubao-seed-2.0-lite\",\n\t\t\"doubao-seed-2.0-mini\",\n\t\t\"minimax-m2.7\",\n\t\t\"minimax-m3\",\n\t\t\"glm-5.2\",\n\t\t\"glm-latest\",\n\t\t\"deepseek-v4-flash\",\n\t\t\"deepseek-v4-pro\",\n\t\t\"kimi-k2.6\",\n\t\t\"kimi-k2.7-code\",\n\t}\n}\n\n// volcengineCodingPlanModelIDs 火山方舟 Coding Plan(/api/coding) 入口的兜底文本模型清单。\n// 与 Agent Plan 差异:Coding Plan 独有 doubao-seed-code,不含 doubao-seed-2.0-mini/glm-latest。\n// 清单来源:火山方舟 Coding Plan 套餐概览(2026-07)。\nfunc volcengineCodingPlanModelIDs() []string {\n\treturn []string{\n\t\t\"doubao-seed-2.0-code\",\n\t\t\"doubao-seed-2.0-pro\",\n\t\t\"doubao-seed-2.0-lite\",\n\t\t\"doubao-seed-code\",\n\t\t\"minimax-m2.7\",\n\t\t\"minimax-m3\",\n\t\t\"glm-5.2\",\n\t\t\"deepseek-v4-flash\",\n\t\t\"deepseek-v4-pro\",\n\t\t\"kimi-k2.6\",\n\t\t\"kimi-k2.7-code\",\n\t}\n}\n\nfunc mimoExcludeModelPatterns() []string {\n\treturn []string{\n\t\t`^mimo-v2\\.5-(?:asr|tts(?:-.+)?)$`,\n\t}\n}\n\n// LookupBuiltinManifest 根据上游 baseURL 和 serviceType 查找匹配的内置清单。\n// 匹配规则:BaseURLPattern 作为 host 或 host+path 前缀匹配。\n// 优先精确 host 匹配,其次 host+path 前缀匹配(最长前缀优先)。\n// 返回匹配的清单和 true;未命中返回零值和 false。\nfunc LookupBuiltinManifest(baseURL string, serviceType string) (BuiltinModelsManifest, bool) {\n\tnormalized := normalizeBaseURLForManifest(baseURL)\n\tif normalized == \"\" {\n\t\treturn BuiltinModelsManifest{}, false\n\t}\n\n\tif manifest, found := lookupBuiltinManifestIn(runtimeBuiltinModelsManifests(), normalized, serviceType); found {\n\t\treturn manifest, true\n\t}\n\treturn lookupBuiltinManifestIn(builtinModelsManifests, normalized, serviceType)\n}\n\n// ResolveBuiltinModelsURL 返回清单声明的模型列表地址。\n// 为避免把 API Key 发送到第三方主机,覆盖地址必须使用 HTTPS 且与渠道 baseURL 同主机。\nfunc ResolveBuiltinModelsURL(baseURL string, serviceType string) (string, bool) {\n\tmanifest, ok := LookupBuiltinManifest(baseURL, strings.ToLower(strings.TrimSpace(serviceType)))\n\tif !ok || strings.TrimSpace(manifest.ModelsURL) == \"\" {\n\t\treturn \"\", false\n\t}\n\tbase, baseErr := url.Parse(strings.TrimSuffix(strings.TrimSpace(baseURL), \"#\"))\n\tmodels, modelsErr := url.Parse(strings.TrimSpace(manifest.ModelsURL))\n\tif baseErr != nil || modelsErr != nil || !strings.EqualFold(models.Scheme, \"https\") ||\n\t\tbase.Hostname() == \"\" || models.User != nil || models.Fragment != \"\" || !sameURLServer(base, models) {\n\t\treturn \"\", false\n\t}\n\treturn models.String(), true\n}\n\nfunc runtimeBuiltinModelsManifests() []BuiltinModelsManifest {\n\tbundle := presetstore.Default().Get()\n\tif bundle == nil || bundle.BuiltinModelsManifests == nil || len(bundle.BuiltinModelsManifests.Manifests) == 0 {\n\t\treturn nil\n\t}\n\tmanifests := make([]BuiltinModelsManifest, 0, len(bundle.BuiltinModelsManifests.Manifests))\n\tfor _, entry := range bundle.BuiltinModelsManifests.Manifests {\n\t\tmanifests = append(manifests, BuiltinModelsManifest{\n\t\t\tBaseURLPattern: entry.BaseURLPattern,\n\t\t\tServiceType: entry.ServiceType,\n\t\t\tPlanHint: entry.PlanHint,\n\t\t\tModelsURL: entry.ModelsURL,\n\t\t\tModelIDs: append([]string(nil), entry.ModelIDs...),\n\t\t\tExcludeModelPatterns: append([]string(nil), entry.ExcludeModelPatterns...),\n\t\t\tDisableProbe: entry.DisableProbe,\n\t\t})\n\t}\n\treturn manifests\n}\n\nfunc lookupBuiltinManifestIn(manifests []BuiltinModelsManifest, normalized string, serviceType string) (BuiltinModelsManifest, bool) {\n\tif len(manifests) == 0 {\n\t\treturn BuiltinModelsManifest{}, false\n\t}\n\tvar bestMatch BuiltinModelsManifest\n\tvar bestMatchLen int\n\tfound := false\n\tfor _, manifest := range manifests {\n\t\tif manifest.ServiceType != serviceType {\n\t\t\tcontinue\n\t\t}\n\t\tpattern := strings.ToLower(manifest.BaseURLPattern)\n\t\tif !matchManifestPattern(normalized, pattern) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(pattern) > bestMatchLen {\n\t\t\tbestMatch = manifest\n\t\t\tbestMatchLen = len(pattern)\n\t\t\tfound = true\n\t\t}\n\t}\n\treturn bestMatch, found\n}\n\n// matchManifestPattern 检查 normalized baseURL 是否匹配 manifest pattern。\n// pattern 可以是纯 host(精确匹配)或 host+path 前缀匹配。\nfunc matchManifestPattern(normalized string, pattern string) bool {\n\t// 精确匹配\n\tif normalized == pattern {\n\t\treturn true\n\t}\n\t// host 精确匹配(pattern 不含路径,normalized 可能含路径)\n\tif !strings.Contains(pattern, \"/\") && strings.HasPrefix(normalized, pattern) {\n\t\trest := normalized[len(pattern):]\n\t\treturn rest == \"\" || rest[0] == '/'\n\t}\n\t// host+path 前缀匹配\n\treturn strings.HasPrefix(normalized, pattern)\n}\n\n// normalizeBaseURLForManifest 将 baseURL 规范化为 host 或 host+path 形式用于匹配。\n// 去掉 scheme(https://)、尾部 /、# 标记、版本段(/v1 等)。\nfunc normalizeBaseURLForManifest(baseURL string) string {\n\ts := strings.ToLower(strings.TrimSpace(baseURL))\n\tif s == \"\" {\n\t\treturn \"\"\n\t}\n\t// 去掉 scheme\n\tif idx := strings.Index(s, \"://\"); idx >= 0 {\n\t\ts = s[idx+3:]\n\t}\n\t// 去掉 #\n\ts = strings.TrimSuffix(s, \"#\")\n\t// 去掉尾部 /\n\ts = strings.TrimRight(s, \"/\")\n\treturn s\n}\n"}, "788018ad7a01699347a7a3852bacd2be1bcd0347": {"t": 1785759330.762055, "n": 9530, "h": "8a396b6420764d8a", "c": "package config\n\nimport (\n\t\"strings\"\n\n\t\"github.com/BenedictKing/ccx/internal/presetstore\"\n)\n\n// BuiltinModelsManifest 内置模型清单条目。\n// 对部分官方订阅入口(如 Claude OAuth 订阅、Codex plan 入口),\n// 上游 models 接口可能不存在(404)或返回错误清单。\n// 命中时直接使用 ModelIDs 作为该入口的可用模型列表,跳过上游探测。\ntype BuiltinModelsManifest struct {\n\t// BaseURLPattern 精确 host 或 host+path 前缀匹配,\n\t// 如 \"api.anthropic.com\" 或 \"api.anthropic.com/v1\"。\n\tBaseURLPattern string `json:\"baseUrlPattern\"`\n\n\t// ServiceType 运行时上游服务类型;claude 会归一为 messages,OpenAI Chat 兼容入口用 openai。\n\tServiceType string `json:\"serviceType\"`\n\n\t// PlanHint 订阅类型提示,仅用于文档和日志,不影响匹配逻辑。\n\tPlanHint string `json:\"planHint,omitempty\"`\n\n\t// ModelIDs 该入口实际可用的模型 ID 清单。\n\tModelIDs []string `json:\"modelIds\"`\n\n\t// ExcludeModelPatterns 从上游 /v1/models 返回中剔除的模型 ID 正则。\n\tExcludeModelPatterns []string `json:\"excludeModelPatterns,omitempty\"`\n\n\t// DisableProbe 为 true 时 Discovery 流程跳过 GET /v1/models,\n\t// 直接用 ModelIDs 生成 ModelProfile[]。\n\tDisableProbe bool `json:\"disableProbe\"`\n}\n\n// builtinModelsManifests 内置模型清单。\n// 当前仅收录从仓库代码/模板/测试中能确认的官方入口。\n// 新增条目须附注证据来源,不臆造 baseURL。\nvar builtinModelsManifests = []BuiltinModelsManifest{\n\t// Anthropic 官方 API 入口(来源:buildClaudeCompatibleModelsURLs 测试、\n\t// expectedRequestUrls 测试、前端 locale placeholder 确认 api.anthropic.com)。\n\t// 该入口的 /v1/models 正常可用,DisableProbe=false;\n\t// 当上游探测失败时回退使用此清单。\n\t{\n\t\tBaseURLPattern: \"api.anthropic.com\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"anthropic_api\",\n\t\tModelIDs: []string{\n\t\t\t\"claude-fable-5\",\n\t\t\t\"claude-mythos-5\",\n\t\t\t\"claude-opus-4-8\",\n\t\t\t\"claude-opus-4-7\",\n\t\t\t\"claude-opus-4-6\",\n\t\t\t\"claude-sonnet-5\",\n\t\t\t\"claude-sonnet-4-6\",\n\t\t\t\"claude-sonnet-4-5\",\n\t\t\t\"claude-opus-4-5\",\n\t\t\t\"claude-haiku-4-5\",\n\t\t},\n\t\tDisableProbe: false,\n\t},\n\t// 小米 MiMo Anthropic 兼容入口(来源:provider_templates.go 与 docs/providers/mimo.md)。\n\t// Anthropic 协议入口可用性通过 /v1/messages 验证;/v1/models 不作为能力判定依据。\n\t{\n\t\tBaseURLPattern: \"api.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_payg_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-cn.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_cn_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_sgp_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-ams.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_ams_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t// 小米 MiMo OpenAI Chat 兼容入口(来源:provider_templates.go 与 docs/providers/mimo.md)。\n\t// /v1/models 可用于发现;返回 ASR/TTS 等非文本模型时通过 ExcludeModelPatterns 过滤。\n\t{\n\t\tBaseURLPattern: \"api.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_payg_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-cn.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_token_plan_cn_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-sgp.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_token_plan_sgp_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-ams.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_token_plan_ams_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t// 火山方舟 Agent/Coding Plan 套餐入口(来源:provider_templates.go volcengine 模板)。\n\t// 套餐模型发现依赖火山云管控面签名接口,普通推理 Key 无法通过 /v1/models 探测;\n\t// 未绑定 Access Key 时用此清单兜底,让渠道立即可用。DisableProbe=true。\n\t// BaseURLPattern 用 host+path 前缀(不含版本段),以同时匹配 openai 入口的 /v3 后缀。\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/plan\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"volcengine_plan_anthropic\",\n\t\tModelIDs: volcengineAgentPlanModelIDs(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/coding\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"volcengine_coding_anthropic\",\n\t\tModelIDs: volcengineCodingPlanModelIDs(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/plan\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"volcengine_plan_openai\",\n\t\tModelIDs: volcengineAgentPlanModelIDs(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/coding\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"volcengine_coding_openai\",\n\t\tModelIDs: volcengineCodingPlanModelIDs(),\n\t\tDisableProbe: true,\n\t},\n}\n\nfunc mimoModelIDs() []string {\n\treturn []string{\n\t\t\"mimo-v2.5-pro\",\n\t\t\"mimo-v2.5\",\n\t}\n}\n\n// volcengineAgentPlanModelIDs 火山方舟 Agent Plan(/api/plan) 入口的兜底文本模型清单。\n// 当用户未绑定火山云 Access Key(无法调用管控面模型发现接口)时,\n// 用此清单让渠道立即可用;绑定 Access Key 后由 FetchModels 覆盖为真实清单。\n// 清单来源:火山方舟 Agent Plan 套餐概览(2026-07),与 Coding Plan 略有差异。\nfunc volcengineAgentPlanModelIDs() []string {\n\treturn []string{\n\t\t\"doubao-seed-2.0-code\",\n\t\t\"doubao-seed-2.0-pro\",\n\t\t\"doubao-seed-2.0-lite\",\n\t\t\"doubao-seed-2.0-mini\",\n\t\t\"minimax-m2.7\",\n\t\t\"minimax-m3\",\n\t\t\"glm-5.2\",\n\t\t\"glm-latest\",\n\t\t\"deepseek-v4-flash\",\n\t\t\"deepseek-v4-pro\",\n\t\t\"kimi-k2.6\",\n\t\t\"kimi-k2.7-code\",\n\t}\n}\n\n// volcengineCodingPlanModelIDs 火山方舟 Coding Plan(/api/coding) 入口的兜底文本模型清单。\n// 与 Agent Plan 差异:Coding Plan 独有 doubao-seed-code,不含 doubao-seed-2.0-mini/glm-latest。\n// 清单来源:火山方舟 Coding Plan 套餐概览(2026-07)。\nfunc volcengineCodingPlanModelIDs() []string {\n\treturn []string{\n\t\t\"doubao-seed-2.0-code\",\n\t\t\"doubao-seed-2.0-pro\",\n\t\t\"doubao-seed-2.0-lite\",\n\t\t\"doubao-seed-code\",\n\t\t\"minimax-m2.7\",\n\t\t\"minimax-m3\",\n\t\t\"glm-5.2\",\n\t\t\"deepseek-v4-flash\",\n\t\t\"deepseek-v4-pro\",\n\t\t\"kimi-k2.6\",\n\t\t\"kimi-k2.7-code\",\n\t}\n}\n\nfunc mimoExcludeModelPatterns() []string {\n\treturn []string{\n\t\t`^mimo-v2\\.5-(?:asr|tts(?:-.+)?)$`,\n\t}\n}\n\n// LookupBuiltinManifest 根据上游 baseURL 和 serviceType 查找匹配的内置清单。\n// 匹配规则:BaseURLPattern 作为 host 或 host+path 前缀匹配。\n// 优先精确 host 匹配,其次 host+path 前缀匹配(最长前缀优先)。\n// 返回匹配的清单和 true;未命中返回零值和 false。\nfunc LookupBuiltinManifest(baseURL string, serviceType string) (BuiltinModelsManifest, bool) {\n\tnormalized := normalizeBaseURLForManifest(baseURL)\n\tif normalized == \"\" {\n\t\treturn BuiltinModelsManifest{}, false\n\t}\n\n\tif manifest, found := lookupBuiltinManifestIn(runtimeBuiltinModelsManifests(), normalized, serviceType); found {\n\t\treturn manifest, true\n\t}\n\treturn lookupBuiltinManifestIn(builtinModelsManifests, normalized, serviceType)\n}\n\nfunc runtimeBuiltinModelsManifests() []BuiltinModelsManifest {\n\tbundle := presetstore.Default().Get()\n\tif bundle == nil || bundle.BuiltinModelsManifests == nil || len(bundle.BuiltinModelsManifests.Manifests) == 0 {\n\t\treturn nil\n\t}\n\tmanifests := make([]BuiltinModelsManifest, 0, len(bundle.BuiltinModelsManifests.Manifests))\n\tfor _, entry := range bundle.BuiltinModelsManifests.Manifests {\n\t\tmanifests = append(manifests, BuiltinModelsManifest{\n\t\t\tBaseURLPattern: entry.BaseURLPattern,\n\t\t\tServiceType: entry.ServiceType,\n\t\t\tPlanHint: entry.PlanHint,\n\t\t\tModelIDs: append([]string(nil), entry.ModelIDs...),\n\t\t\tExcludeModelPatterns: append([]string(nil), entry.ExcludeModelPatterns...),\n\t\t\tDisableProbe: entry.DisableProbe,\n\t\t})\n\t}\n\treturn manifests\n}\n\nfunc lookupBuiltinManifestIn(manifests []BuiltinModelsManifest, normalized string, serviceType string) (BuiltinModelsManifest, bool) {\n\tif len(manifests) == 0 {\n\t\treturn BuiltinModelsManifest{}, false\n\t}\n\tvar bestMatch BuiltinModelsManifest\n\tvar bestMatchLen int\n\tfound := false\n\tfor _, manifest := range manifests {\n\t\tif manifest.ServiceType != serviceType {\n\t\t\tcontinue\n\t\t}\n\t\tpattern := strings.ToLower(manifest.BaseURLPattern)\n\t\tif !matchManifestPattern(normalized, pattern) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(pattern) > bestMatchLen {\n\t\t\tbestMatch = manifest\n\t\t\tbestMatchLen = len(pattern)\n\t\t\tfound = true\n\t\t}\n\t}\n\treturn bestMatch, found\n}\n\n// matchManifestPattern 检查 normalized baseURL 是否匹配 manifest pattern。\n// pattern 可以是纯 host(精确匹配)或 host+path 前缀匹配。\nfunc matchManifestPattern(normalized string, pattern string) bool {\n\t// 精确匹配\n\tif normalized == pattern {\n\t\treturn true\n\t}\n\t// host 精确匹配(pattern 不含路径,normalized 可能含路径)\n\tif !strings.Contains(pattern, \"/\") && strings.HasPrefix(normalized, pattern) {\n\t\trest := normalized[len(pattern):]\n\t\treturn rest == \"\" || rest[0] == '/'\n\t}\n\t// host+path 前缀匹配\n\treturn strings.HasPrefix(normalized, pattern)\n}\n\n// normalizeBaseURLForManifest 将 baseURL 规范化为 host 或 host+path 形式用于匹配。\n// 去掉 scheme(https://)、尾部 /、# 标记、版本段(/v1 等)。\nfunc normalizeBaseURLForManifest(baseURL string) string {\n\ts := strings.ToLower(strings.TrimSpace(baseURL))\n\tif s == \"\" {\n\t\treturn \"\"\n\t}\n\t// 去掉 scheme\n\tif idx := strings.Index(s, \"://\"); idx >= 0 {\n\t\ts = s[idx+3:]\n\t}\n\t// 去掉 #\n\ts = strings.TrimSuffix(s, \"#\")\n\t// 去掉尾部 /\n\ts = strings.TrimRight(s, \"/\")\n\treturn s\n}\n"}, "2e6ca48e0a7beaff0dacf80672147c7e9e0db22f": {"t": 1785759332.3117588, "n": 8989, "h": "1b008a841fc088de", "c": "package config\n\nimport (\n\t\"strings\"\n\n\t\"github.com/BenedictKing/ccx/internal/presetstore\"\n)\n\n// BuiltinModelsManifest 内置模型清单条目。\n// 对部分官方订阅入口(如 Claude OAuth 订阅、Codex plan 入口),\n// 上游 models 接口可能不存在(404)或返回错误清单。\n// 命中时直接使用 ModelIDs 作为该入口的可用模型列表,跳过上游探测。\ntype BuiltinModelsManifest struct {\n\t// BaseURLPattern 精确 host 或 host+path 前缀匹配,\n\t// 如 \"api.anthropic.com\" 或 \"api.anthropic.com/v1\"。\n\tBaseURLPattern string `json:\"baseUrlPattern\"`\n\n\t// ServiceType 运行时上游服务类型;claude 会归一为 messages,OpenAI Chat 兼容入口用 openai。\n\tServiceType string `json:\"serviceType\"`\n\n\t// PlanHint 订阅类型提示,仅用于文档和日志,不影响匹配逻辑。\n\tPlanHint string `json:\"planHint,omitempty\"`\n\n\t// ModelIDs 该入口实际可用的模型 ID 清单。\n\tModelIDs []string `json:\"modelIds\"`\n\n\t// ExcludeModelPatterns 从上游 /v1/models 返回中剔除的模型 ID 正则。\n\tExcludeModelPatterns []string `json:\"excludeModelPatterns,omitempty\"`\n\n\t// DisableProbe 为 true 时 Discovery 流程跳过 GET /v1/models,\n\t// 直接用 ModelIDs 生成 ModelProfile[]。\n\tDisableProbe bool `json:\"disableProbe\"`\n}\n\n// builtinModelsManifests 内置模型清单。\n// 当前仅收录从仓库代码/模板/测试中能确认的官方入口。\n// 新增条目须附注证据来源,不臆造 baseURL。\nvar builtinModelsManifests = []BuiltinModelsManifest{\n\t// Anthropic 官方 API 入口(来源:buildClaudeCompatibleModelsURLs 测试、\n\t// expectedRequestUrls 测试、前端 locale placeholder 确认 api.anthropic.com)。\n\t// 该入口的 /v1/models 正常可用,DisableProbe=false;\n\t// 当上游探测失败时回退使用此清单。\n\t{\n\t\tBaseURLPattern: \"api.anthropic.com\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"anthropic_api\",\n\t\tModelIDs: []string{\n\t\t\t\"claude-fable-5\",\n\t\t\t\"claude-mythos-5\",\n\t\t\t\"claude-opus-4-8\",\n\t\t\t\"claude-opus-4-7\",\n\t\t\t\"claude-opus-4-6\",\n\t\t\t\"claude-sonnet-5\",\n\t\t\t\"claude-sonnet-4-6\",\n\t\t\t\"claude-sonnet-4-5\",\n\t\t\t\"claude-opus-4-5\",\n\t\t\t\"claude-haiku-4-5\",\n\t\t},\n\t\tDisableProbe: false,\n\t},\n\t// 小米 MiMo Anthropic 兼容入口(来源:provider_templates.go 与 docs/providers/mimo.md)。\n\t// Anthropic 协议入口可用性通过 /v1/messages 验证;/v1/models 不作为能力判定依据。\n\t{\n\t\tBaseURLPattern: \"api.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_payg_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-cn.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_cn_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_sgp_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-ams.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_ams_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t// 小米 MiMo OpenAI Chat 兼容入口(来源:provider_templates.go 与 docs/providers/mimo.md)。\n\t// /v1/models 可用于发现;返回 ASR/TTS 等非文本模型时通过 ExcludeModelPatterns 过滤。\n\t{\n\t\tBaseURLPattern: \"api.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_payg_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-cn.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_token_plan_cn_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-sgp.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_token_plan_sgp_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-ams.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_token_plan_ams_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t// 火山方舟 Agent/Coding Plan 套餐入口(来源:provider_templates.go volcengine 模板)。\n\t// 套餐模型发现依赖火山云管控面签名接口,普通推理 Key 无法通过 /v1/models 探测;\n\t// 未绑定 Access Key 时用此清单兜底,让渠道立即可用。DisableProbe=true。\n\t// BaseURLPattern 用 host+path 前缀(不含版本段),以同时匹配 openai 入口的 /v3 后缀。\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/plan\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"volcengine_plan_anthropic\",\n\t\tModelIDs: volcengineFallbackModelIDs(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/coding\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"volcengine_coding_anthropic\",\n\t\tModelIDs: volcengineFallbackModelIDs(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/plan\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"volcengine_plan_openai\",\n\t\tModelIDs: volcengineFallbackModelIDs(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/coding\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"volcengine_coding_openai\",\n\t\tModelIDs: volcengineFallbackModelIDs(),\n\t\tDisableProbe: true,\n\t},\n}\n\nfunc mimoModelIDs() []string {\n\treturn []string{\n\t\t\"mimo-v2.5-pro\",\n\t\t\"mimo-v2.5\",\n\t}\n}\n\n// volcengineFallbackModelIDs 火山方舟套餐入口的兜底模型清单。\n// 当用户未绑定火山云 Access Key(无法调用管控面模型发现接口)时,\n// 用此清单让渠道立即可用;绑定 Access Key 后由 FetchModels 覆盖为真实清单。\n// 清单来源:火山方舟 Agent/Coding Plan 2026-07 公布的套餐可用模型。\nfunc volcengineFallbackModelIDs() []string {\n\treturn []string{\n\t\t\"doubao-seed-2.0-code\",\n\t\t\"doubao-seed-2.0-pro\",\n\t\t\"doubao-seed-2.0-lite\",\n\t\t\"doubao-seed-code\",\n\t\t\"minimax-m2.7\",\n\t\t\"minimax-m3\",\n\t\t\"glm-5.2\",\n\t\t\"glm-latest\",\n\t\t\"deepseek-v4-flash\",\n\t\t\"deepseek-v4-pro\",\n\t\t\"kimi-k2.6\",\n\t\t\"kimi-k2.7-code\",\n\t}\n}\n\nfunc mimoExcludeModelPatterns() []string {\n\treturn []string{\n\t\t`^mimo-v2\\.5-(?:asr|tts(?:-.+)?)$`,\n\t}\n}\n\n// LookupBuiltinManifest 根据上游 baseURL 和 serviceType 查找匹配的内置清单。\n// 匹配规则:BaseURLPattern 作为 host 或 host+path 前缀匹配。\n// 优先精确 host 匹配,其次 host+path 前缀匹配(最长前缀优先)。\n// 返回匹配的清单和 true;未命中返回零值和 false。\nfunc LookupBuiltinManifest(baseURL string, serviceType string) (BuiltinModelsManifest, bool) {\n\tnormalized := normalizeBaseURLForManifest(baseURL)\n\tif normalized == \"\" {\n\t\treturn BuiltinModelsManifest{}, false\n\t}\n\n\tif manifest, found := lookupBuiltinManifestIn(runtimeBuiltinModelsManifests(), normalized, serviceType); found {\n\t\treturn manifest, true\n\t}\n\treturn lookupBuiltinManifestIn(builtinModelsManifests, normalized, serviceType)\n}\n\nfunc runtimeBuiltinModelsManifests() []BuiltinModelsManifest {\n\tbundle := presetstore.Default().Get()\n\tif bundle == nil || bundle.BuiltinModelsManifests == nil || len(bundle.BuiltinModelsManifests.Manifests) == 0 {\n\t\treturn nil\n\t}\n\tmanifests := make([]BuiltinModelsManifest, 0, len(bundle.BuiltinModelsManifests.Manifests))\n\tfor _, entry := range bundle.BuiltinModelsManifests.Manifests {\n\t\tmanifests = append(manifests, BuiltinModelsManifest{\n\t\t\tBaseURLPattern: entry.BaseURLPattern,\n\t\t\tServiceType: entry.ServiceType,\n\t\t\tPlanHint: entry.PlanHint,\n\t\t\tModelIDs: append([]string(nil), entry.ModelIDs...),\n\t\t\tExcludeModelPatterns: append([]string(nil), entry.ExcludeModelPatterns...),\n\t\t\tDisableProbe: entry.DisableProbe,\n\t\t})\n\t}\n\treturn manifests\n}\n\nfunc lookupBuiltinManifestIn(manifests []BuiltinModelsManifest, normalized string, serviceType string) (BuiltinModelsManifest, bool) {\n\tif len(manifests) == 0 {\n\t\treturn BuiltinModelsManifest{}, false\n\t}\n\tvar bestMatch BuiltinModelsManifest\n\tvar bestMatchLen int\n\tfound := false\n\tfor _, manifest := range manifests {\n\t\tif manifest.ServiceType != serviceType {\n\t\t\tcontinue\n\t\t}\n\t\tpattern := strings.ToLower(manifest.BaseURLPattern)\n\t\tif !matchManifestPattern(normalized, pattern) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(pattern) > bestMatchLen {\n\t\t\tbestMatch = manifest\n\t\t\tbestMatchLen = len(pattern)\n\t\t\tfound = true\n\t\t}\n\t}\n\treturn bestMatch, found\n}\n\n// matchManifestPattern 检查 normalized baseURL 是否匹配 manifest pattern。\n// pattern 可以是纯 host(精确匹配)或 host+path 前缀匹配。\nfunc matchManifestPattern(normalized string, pattern string) bool {\n\t// 精确匹配\n\tif normalized == pattern {\n\t\treturn true\n\t}\n\t// host 精确匹配(pattern 不含路径,normalized 可能含路径)\n\tif !strings.Contains(pattern, \"/\") && strings.HasPrefix(normalized, pattern) {\n\t\trest := normalized[len(pattern):]\n\t\treturn rest == \"\" || rest[0] == '/'\n\t}\n\t// host+path 前缀匹配\n\treturn strings.HasPrefix(normalized, pattern)\n}\n\n// normalizeBaseURLForManifest 将 baseURL 规范化为 host 或 host+path 形式用于匹配。\n// 去掉 scheme(https://)、尾部 /、# 标记、版本段(/v1 等)。\nfunc normalizeBaseURLForManifest(baseURL string) string {\n\ts := strings.ToLower(strings.TrimSpace(baseURL))\n\tif s == \"\" {\n\t\treturn \"\"\n\t}\n\t// 去掉 scheme\n\tif idx := strings.Index(s, \"://\"); idx >= 0 {\n\t\ts = s[idx+3:]\n\t}\n\t// 去掉 #\n\ts = strings.TrimSuffix(s, \"#\")\n\t// 去掉尾部 /\n\ts = strings.TrimRight(s, \"/\")\n\treturn s\n}\n"}, "5cfccf226a320c70c7da2c8c594bc5980bba37a0": {"t": 1785759334.7624176, "n": 7397, "h": "4a02591985e9e459", "c": "package config\n\nimport (\n\t\"strings\"\n\n\t\"github.com/BenedictKing/ccx/internal/presetstore\"\n)\n\n// BuiltinModelsManifest 内置模型清单条目。\n// 对部分官方订阅入口(如 Claude OAuth 订阅、Codex plan 入口),\n// 上游 models 接口可能不存在(404)或返回错误清单。\n// 命中时直接使用 ModelIDs 作为该入口的可用模型列表,跳过上游探测。\ntype BuiltinModelsManifest struct {\n\t// BaseURLPattern 精确 host 或 host+path 前缀匹配,\n\t// 如 \"api.anthropic.com\" 或 \"api.anthropic.com/v1\"。\n\tBaseURLPattern string `json:\"baseUrlPattern\"`\n\n\t// ServiceType 运行时上游服务类型;claude 会归一为 messages,OpenAI Chat 兼容入口用 openai。\n\tServiceType string `json:\"serviceType\"`\n\n\t// PlanHint 订阅类型提示,仅用于文档和日志,不影响匹配逻辑。\n\tPlanHint string `json:\"planHint,omitempty\"`\n\n\t// ModelIDs 该入口实际可用的模型 ID 清单。\n\tModelIDs []string `json:\"modelIds\"`\n\n\t// ExcludeModelPatterns 从上游 /v1/models 返回中剔除的模型 ID 正则。\n\tExcludeModelPatterns []string `json:\"excludeModelPatterns,omitempty\"`\n\n\t// DisableProbe 为 true 时 Discovery 流程跳过 GET /v1/models,\n\t// 直接用 ModelIDs 生成 ModelProfile[]。\n\tDisableProbe bool `json:\"disableProbe\"`\n}\n\n// builtinModelsManifests 内置模型清单。\n// 当前仅收录从仓库代码/模板/测试中能确认的官方入口。\n// 新增条目须附注证据来源,不臆造 baseURL。\nvar builtinModelsManifests = []BuiltinModelsManifest{\n\t// Anthropic 官方 API 入口(来源:buildClaudeCompatibleModelsURLs 测试、\n\t// expectedRequestUrls 测试、前端 locale placeholder 确认 api.anthropic.com)。\n\t// 该入口的 /v1/models 正常可用,DisableProbe=false;\n\t// 当上游探测失败时回退使用此清单。\n\t{\n\t\tBaseURLPattern: \"api.anthropic.com\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"anthropic_api\",\n\t\tModelIDs: []string{\n\t\t\t\"claude-fable-5\",\n\t\t\t\"claude-mythos-5\",\n\t\t\t\"claude-opus-4-8\",\n\t\t\t\"claude-opus-4-7\",\n\t\t\t\"claude-opus-4-6\",\n\t\t\t\"claude-sonnet-5\",\n\t\t\t\"claude-sonnet-4-6\",\n\t\t\t\"claude-sonnet-4-5\",\n\t\t\t\"claude-opus-4-5\",\n\t\t\t\"claude-haiku-4-5\",\n\t\t},\n\t\tDisableProbe: false,\n\t},\n\t// 小米 MiMo Anthropic 兼容入口(来源:provider_templates.go 与 docs/providers/mimo.md)。\n\t// Anthropic 协议入口可用性通过 /v1/messages 验证;/v1/models 不作为能力判定依据。\n\t{\n\t\tBaseURLPattern: \"api.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_payg_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-cn.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_cn_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_sgp_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-ams.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_ams_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t// 小米 MiMo OpenAI Chat 兼容入口(来源:provider_templates.go 与 docs/providers/mimo.md)。\n\t// /v1/models 可用于发现;返回 ASR/TTS 等非文本模型时通过 ExcludeModelPatterns 过滤。\n\t{\n\t\tBaseURLPattern: \"api.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_payg_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-cn.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_token_plan_cn_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-sgp.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_token_plan_sgp_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-ams.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_token_plan_ams_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n}\n\nfunc mimoModelIDs() []string {\n\treturn []string{\n\t\t\"mimo-v2.5-pro\",\n\t\t\"mimo-v2.5\",\n\t}\n}\n\nfunc mimoExcludeModelPatterns() []string {\n\treturn []string{\n\t\t`^mimo-v2\\.5-(?:asr|tts(?:-.+)?)$`,\n\t}\n}\n\n// LookupBuiltinManifest 根据上游 baseURL 和 serviceType 查找匹配的内置清单。\n// 匹配规则:BaseURLPattern 作为 host 或 host+path 前缀匹配。\n// 优先精确 host 匹配,其次 host+path 前缀匹配(最长前缀优先)。\n// 返回匹配的清单和 true;未命中返回零值和 false。\nfunc LookupBuiltinManifest(baseURL string, serviceType string) (BuiltinModelsManifest, bool) {\n\tnormalized := normalizeBaseURLForManifest(baseURL)\n\tif normalized == \"\" {\n\t\treturn BuiltinModelsManifest{}, false\n\t}\n\n\tif manifest, found := lookupBuiltinManifestIn(runtimeBuiltinModelsManifests(), normalized, serviceType); found {\n\t\treturn manifest, true\n\t}\n\treturn lookupBuiltinManifestIn(builtinModelsManifests, normalized, serviceType)\n}\n\nfunc runtimeBuiltinModelsManifests() []BuiltinModelsManifest {\n\tbundle := presetstore.Default().Get()\n\tif bundle == nil || bundle.BuiltinModelsManifests == nil || len(bundle.BuiltinModelsManifests.Manifests) == 0 {\n\t\treturn nil\n\t}\n\tmanifests := make([]BuiltinModelsManifest, 0, len(bundle.BuiltinModelsManifests.Manifests))\n\tfor _, entry := range bundle.BuiltinModelsManifests.Manifests {\n\t\tmanifests = append(manifests, BuiltinModelsManifest{\n\t\t\tBaseURLPattern: entry.BaseURLPattern,\n\t\t\tServiceType: entry.ServiceType,\n\t\t\tPlanHint: entry.PlanHint,\n\t\t\tModelIDs: append([]string(nil), entry.ModelIDs...),\n\t\t\tExcludeModelPatterns: append([]string(nil), entry.ExcludeModelPatterns...),\n\t\t\tDisableProbe: entry.DisableProbe,\n\t\t})\n\t}\n\treturn manifests\n}\n\nfunc lookupBuiltinManifestIn(manifests []BuiltinModelsManifest, normalized string, serviceType string) (BuiltinModelsManifest, bool) {\n\tif len(manifests) == 0 {\n\t\treturn BuiltinModelsManifest{}, false\n\t}\n\tvar bestMatch BuiltinModelsManifest\n\tvar bestMatchLen int\n\tfound := false\n\tfor _, manifest := range manifests {\n\t\tif manifest.ServiceType != serviceType {\n\t\t\tcontinue\n\t\t}\n\t\tpattern := strings.ToLower(manifest.BaseURLPattern)\n\t\tif !matchManifestPattern(normalized, pattern) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(pattern) > bestMatchLen {\n\t\t\tbestMatch = manifest\n\t\t\tbestMatchLen = len(pattern)\n\t\t\tfound = true\n\t\t}\n\t}\n\treturn bestMatch, found\n}\n\n// matchManifestPattern 检查 normalized baseURL 是否匹配 manifest pattern。\n// pattern 可以是纯 host(精确匹配)或 host+path 前缀匹配。\nfunc matchManifestPattern(normalized string, pattern string) bool {\n\t// 精确匹配\n\tif normalized == pattern {\n\t\treturn true\n\t}\n\t// host 精确匹配(pattern 不含路径,normalized 可能含路径)\n\tif !strings.Contains(pattern, \"/\") && strings.HasPrefix(normalized, pattern) {\n\t\trest := normalized[len(pattern):]\n\t\treturn rest == \"\" || rest[0] == '/'\n\t}\n\t// host+path 前缀匹配\n\treturn strings.HasPrefix(normalized, pattern)\n}\n\n// normalizeBaseURLForManifest 将 baseURL 规范化为 host 或 host+path 形式用于匹配。\n// 去掉 scheme(https://)、尾部 /、# 标记、版本段(/v1 等)。\nfunc normalizeBaseURLForManifest(baseURL string) string {\n\ts := strings.ToLower(strings.TrimSpace(baseURL))\n\tif s == \"\" {\n\t\treturn \"\"\n\t}\n\t// 去掉 scheme\n\tif idx := strings.Index(s, \"://\"); idx >= 0 {\n\t\ts = s[idx+3:]\n\t}\n\t// 去掉 #\n\ts = strings.TrimSuffix(s, \"#\")\n\t// 去掉尾部 /\n\ts = strings.TrimRight(s, \"/\")\n\treturn s\n}\n"}, "ade2bae08c3ac61539293f45ee34f7fa1b06b4db": {"t": 1785759336.4086318, "n": 6157, "h": "7fea315c7a1ee0fd", "c": "package config\n\nimport (\n\t\"strings\"\n\n\t\"github.com/BenedictKing/ccx/internal/presetstore\"\n)\n\n// BuiltinModelsManifest 内置模型清单条目。\n// 对部分官方订阅入口(如 Claude OAuth 订阅、Codex plan 入口),\n// 上游 models 接口可能不存在(404)或返回错误清单。\n// 命中时直接使用 ModelIDs 作为该入口的可用模型列表,跳过上游探测。\ntype BuiltinModelsManifest struct {\n\t// BaseURLPattern 精确 host 或 host+path 前缀匹配,\n\t// 如 \"api.anthropic.com\" 或 \"api.anthropic.com/v1\"。\n\tBaseURLPattern string `json:\"baseUrlPattern\"`\n\n\t// ServiceType 渠道协议类型:messages/responses/chat/gemini/images/vectors。\n\tServiceType string `json:\"serviceType\"`\n\n\t// PlanHint 订阅类型提示,仅用于文档和日志,不影响匹配逻辑。\n\tPlanHint string `json:\"planHint,omitempty\"`\n\n\t// ModelIDs 该入口实际可用的模型 ID 清单。\n\tModelIDs []string `json:\"modelIds\"`\n\n\t// ExcludeModelPatterns 从上游 /v1/models 返回中剔除的模型 ID 正则。\n\tExcludeModelPatterns []string `json:\"excludeModelPatterns,omitempty\"`\n\n\t// DisableProbe 为 true 时 Discovery 流程跳过 GET /v1/models,\n\t// 直接用 ModelIDs 生成 ModelProfile[]。\n\tDisableProbe bool `json:\"disableProbe\"`\n}\n\n// builtinModelsManifests 内置模型清单。\n// 当前仅收录从仓库代码/模板/测试中能确认的官方入口。\n// 新增条目须附注证据来源,不臆造 baseURL。\nvar builtinModelsManifests = []BuiltinModelsManifest{\n\t// Anthropic 官方 API 入口(来源:buildClaudeCompatibleModelsURLs 测试、\n\t// expectedRequestUrls 测试、前端 locale placeholder 确认 api.anthropic.com)。\n\t// 该入口的 /v1/models 正常可用,DisableProbe=false;\n\t// 当上游探测失败时回退使用此清单。\n\t{\n\t\tBaseURLPattern: \"api.anthropic.com\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"anthropic_api\",\n\t\tModelIDs: []string{\n\t\t\t\"claude-fable-5\",\n\t\t\t\"claude-mythos-5\",\n\t\t\t\"claude-opus-4-8\",\n\t\t\t\"claude-opus-4-7\",\n\t\t\t\"claude-opus-4-6\",\n\t\t\t\"claude-sonnet-5\",\n\t\t\t\"claude-sonnet-4-6\",\n\t\t\t\"claude-sonnet-4-5\",\n\t\t\t\"claude-opus-4-5\",\n\t\t\t\"claude-haiku-4-5\",\n\t\t},\n\t\tDisableProbe: false,\n\t},\n\t// 小米 MiMo Anthropic 兼容入口(来源:provider_templates.go 与 docs/providers/mimo.md)。\n\t// Anthropic 协议入口可用性通过 /v1/messages 验证;/v1/models 不作为能力判定依据。\n\t{\n\t\tBaseURLPattern: \"api.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_payg_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-cn.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_cn_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_sgp_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-ams.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_ams_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n}\n\nfunc mimoModelIDs() []string {\n\treturn []string{\n\t\t\"mimo-v2.5-pro\",\n\t\t\"mimo-v2.5\",\n\t}\n}\n\nfunc mimoExcludeModelPatterns() []string {\n\treturn []string{\n\t\t`^mimo-v2\\.5-(?:asr|tts(?:-.+)?)$`,\n\t}\n}\n\n// LookupBuiltinManifest 根据上游 baseURL 和 serviceType 查找匹配的内置清单。\n// 匹配规则:BaseURLPattern 作为 host 或 host+path 前缀匹配。\n// 优先精确 host 匹配,其次 host+path 前缀匹配(最长前缀优先)。\n// 返回匹配的清单和 true;未命中返回零值和 false。\nfunc LookupBuiltinManifest(baseURL string, serviceType string) (BuiltinModelsManifest, bool) {\n\tnormalized := normalizeBaseURLForManifest(baseURL)\n\tif normalized == \"\" {\n\t\treturn BuiltinModelsManifest{}, false\n\t}\n\n\tif manifest, found := lookupBuiltinManifestIn(runtimeBuiltinModelsManifests(), normalized, serviceType); found {\n\t\treturn manifest, true\n\t}\n\treturn lookupBuiltinManifestIn(builtinModelsManifests, normalized, serviceType)\n}\n\nfunc runtimeBuiltinModelsManifests() []BuiltinModelsManifest {\n\tbundle := presetstore.Default().Get()\n\tif bundle == nil || bundle.BuiltinModelsManifests == nil || len(bundle.BuiltinModelsManifests.Manifests) == 0 {\n\t\treturn nil\n\t}\n\tmanifests := make([]BuiltinModelsManifest, 0, len(bundle.BuiltinModelsManifests.Manifests))\n\tfor _, entry := range bundle.BuiltinModelsManifests.Manifests {\n\t\tmanifests = append(manifests, BuiltinModelsManifest{\n\t\t\tBaseURLPattern: entry.BaseURLPattern,\n\t\t\tServiceType: entry.ServiceType,\n\t\t\tPlanHint: entry.PlanHint,\n\t\t\tModelIDs: append([]string(nil), entry.ModelIDs...),\n\t\t\tExcludeModelPatterns: append([]string(nil), entry.ExcludeModelPatterns...),\n\t\t\tDisableProbe: entry.DisableProbe,\n\t\t})\n\t}\n\treturn manifests\n}\n\nfunc lookupBuiltinManifestIn(manifests []BuiltinModelsManifest, normalized string, serviceType string) (BuiltinModelsManifest, bool) {\n\tif len(manifests) == 0 {\n\t\treturn BuiltinModelsManifest{}, false\n\t}\n\tvar bestMatch BuiltinModelsManifest\n\tvar bestMatchLen int\n\tfound := false\n\tfor _, manifest := range manifests {\n\t\tif manifest.ServiceType != serviceType {\n\t\t\tcontinue\n\t\t}\n\t\tpattern := strings.ToLower(manifest.BaseURLPattern)\n\t\tif !matchManifestPattern(normalized, pattern) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(pattern) > bestMatchLen {\n\t\t\tbestMatch = manifest\n\t\t\tbestMatchLen = len(pattern)\n\t\t\tfound = true\n\t\t}\n\t}\n\treturn bestMatch, found\n}\n\n// matchManifestPattern 检查 normalized baseURL 是否匹配 manifest pattern。\n// pattern 可以是纯 host(精确匹配)或 host+path 前缀匹配。\nfunc matchManifestPattern(normalized string, pattern string) bool {\n\t// 精确匹配\n\tif normalized == pattern {\n\t\treturn true\n\t}\n\t// host 精确匹配(pattern 不含路径,normalized 可能含路径)\n\tif !strings.Contains(pattern, \"/\") && strings.HasPrefix(normalized, pattern) {\n\t\trest := normalized[len(pattern):]\n\t\treturn rest == \"\" || rest[0] == '/'\n\t}\n\t// host+path 前缀匹配\n\treturn strings.HasPrefix(normalized, pattern)\n}\n\n// normalizeBaseURLForManifest 将 baseURL 规范化为 host 或 host+path 形式用于匹配。\n// 去掉 scheme(https://)、尾部 /、# 标记、版本段(/v1 等)。\nfunc normalizeBaseURLForManifest(baseURL string) string {\n\ts := strings.ToLower(strings.TrimSpace(baseURL))\n\tif s == \"\" {\n\t\treturn \"\"\n\t}\n\t// 去掉 scheme\n\tif idx := strings.Index(s, \"://\"); idx >= 0 {\n\t\ts = s[idx+3:]\n\t}\n\t// 去掉 #\n\ts = strings.TrimSuffix(s, \"#\")\n\t// 去掉尾部 /\n\ts = strings.TrimRight(s, \"/\")\n\treturn s\n}\n"}, "238c29d42be793fa97f4f469e72736877f07e24e": {"t": 1785759337.853653, "n": 5506, "h": "5fe5346108e9b455", "c": "package config\n\nimport (\n\t\"strings\"\n\n\t\"github.com/BenedictKing/ccx/internal/presetstore\"\n)\n\n// BuiltinModelsManifest 内置模型清单条目。\n// 对部分官方订阅入口(如 Claude OAuth 订阅、Codex plan 入口),\n// 上游 models 接口可能不存在(404)或返回错误清单。\n// 命中时直接使用 ModelIDs 作为该入口的可用模型列表,跳过上游探测。\ntype BuiltinModelsManifest struct {\n\t// BaseURLPattern 精确 host 或 host+path 前缀匹配,\n\t// 如 \"api.anthropic.com\" 或 \"api.anthropic.com/v1\"。\n\tBaseURLPattern string `json:\"baseUrlPattern\"`\n\n\t// ServiceType 渠道协议类型:messages/responses/chat/gemini/images/vectors。\n\tServiceType string `json:\"serviceType\"`\n\n\t// PlanHint 订阅类型提示,仅用于文档和日志,不影响匹配逻辑。\n\tPlanHint string `json:\"planHint,omitempty\"`\n\n\t// ModelIDs 该入口实际可用的模型 ID 清单。\n\tModelIDs []string `json:\"modelIds\"`\n\n\t// DisableProbe 为 true 时 Discovery 流程跳过 GET /v1/models,\n\t// 直接用 ModelIDs 生成 ModelProfile[]。\n\tDisableProbe bool `json:\"disableProbe\"`\n}\n\n// builtinModelsManifests 内置模型清单。\n// 当前仅收录从仓库代码/模板/测试中能确认的官方入口。\n// 新增条目须附注证据来源,不臆造 baseURL。\nvar builtinModelsManifests = []BuiltinModelsManifest{\n\t// Anthropic 官方 API 入口(来源:buildClaudeCompatibleModelsURLs 测试、\n\t// expectedRequestUrls 测试、前端 locale placeholder 确认 api.anthropic.com)。\n\t// 该入口的 /v1/models 正常可用,DisableProbe=false;\n\t// 当上游探测失败时回退使用此清单。\n\t{\n\t\tBaseURLPattern: \"api.anthropic.com\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"anthropic_api\",\n\t\tModelIDs: []string{\n\t\t\t\"claude-fable-5\",\n\t\t\t\"claude-mythos-5\",\n\t\t\t\"claude-opus-4-8\",\n\t\t\t\"claude-opus-4-7\",\n\t\t\t\"claude-opus-4-6\",\n\t\t\t\"claude-sonnet-5\",\n\t\t\t\"claude-sonnet-4-6\",\n\t\t\t\"claude-sonnet-4-5\",\n\t\t\t\"claude-opus-4-5\",\n\t\t\t\"claude-haiku-4-5\",\n\t\t},\n\t\tDisableProbe: false,\n\t},\n\t// 小米 MiMo Anthropic 兼容入口(来源:provider_templates.go 与 docs/providers/mimo.md)。\n\t// Anthropic 协议入口可用性通过 /v1/messages 验证;/v1/models 不作为能力判定依据。\n\t{\n\t\tBaseURLPattern: \"api.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_payg_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-cn.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_cn_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_sgp_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-ams.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_ams_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tDisableProbe: true,\n\t},\n}\n\nfunc mimoModelIDs() []string {\n\treturn []string{\n\t\t\"mimo-v2.5-pro\",\n\t\t\"mimo-v2.5\",\n\t\t\"mimo-v2-flash\",\n\t}\n}\n\n// LookupBuiltinManifest 根据上游 baseURL 和 serviceType 查找匹配的内置清单。\n// 匹配规则:BaseURLPattern 作为 host 或 host+path 前缀匹配。\n// 优先精确 host 匹配,其次 host+path 前缀匹配(最长前缀优先)。\n// 返回匹配的清单和 true;未命中返回零值和 false。\nfunc LookupBuiltinManifest(baseURL string, serviceType string) (BuiltinModelsManifest, bool) {\n\tnormalized := normalizeBaseURLForManifest(baseURL)\n\tif normalized == \"\" {\n\t\treturn BuiltinModelsManifest{}, false\n\t}\n\n\tif manifest, found := lookupBuiltinManifestIn(runtimeBuiltinModelsManifests(), normalized, serviceType); found {\n\t\treturn manifest, true\n\t}\n\treturn lookupBuiltinManifestIn(builtinModelsManifests, normalized, serviceType)\n}\n\nfunc runtimeBuiltinModelsManifests() []BuiltinModelsManifest {\n\tbundle := presetstore.Default().Get()\n\tif bundle == nil || bundle.BuiltinModelsManifests == nil || len(bundle.BuiltinModelsManifests.Manifests) == 0 {\n\t\treturn nil\n\t}\n\tmanifests := make([]BuiltinModelsManifest, 0, len(bundle.BuiltinModelsManifests.Manifests))\n\tfor _, entry := range bundle.BuiltinModelsManifests.Manifests {\n\t\tmanifests = append(manifests, BuiltinModelsManifest{\n\t\t\tBaseURLPattern: entry.BaseURLPattern,\n\t\t\tServiceType: entry.ServiceType,\n\t\t\tPlanHint: entry.PlanHint,\n\t\t\tModelIDs: append([]string(nil), entry.ModelIDs...),\n\t\t\tDisableProbe: entry.DisableProbe,\n\t\t})\n\t}\n\treturn manifests\n}\n\nfunc lookupBuiltinManifestIn(manifests []BuiltinModelsManifest, normalized string, serviceType string) (BuiltinModelsManifest, bool) {\n\tif len(manifests) == 0 {\n\t\treturn BuiltinModelsManifest{}, false\n\t}\n\tvar bestMatch BuiltinModelsManifest\n\tvar bestMatchLen int\n\tfound := false\n\tfor _, manifest := range manifests {\n\t\tif manifest.ServiceType != serviceType {\n\t\t\tcontinue\n\t\t}\n\t\tpattern := strings.ToLower(manifest.BaseURLPattern)\n\t\tif !matchManifestPattern(normalized, pattern) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(pattern) > bestMatchLen {\n\t\t\tbestMatch = manifest\n\t\t\tbestMatchLen = len(pattern)\n\t\t\tfound = true\n\t\t}\n\t}\n\treturn bestMatch, found\n}\n\n// matchManifestPattern 检查 normalized baseURL 是否匹配 manifest pattern。\n// pattern 可以是纯 host(精确匹配)或 host+path 前缀匹配。\nfunc matchManifestPattern(normalized string, pattern string) bool {\n\t// 精确匹配\n\tif normalized == pattern {\n\t\treturn true\n\t}\n\t// host 精确匹配(pattern 不含路径,normalized 可能含路径)\n\tif !strings.Contains(pattern, \"/\") && strings.HasPrefix(normalized, pattern) {\n\t\trest := normalized[len(pattern):]\n\t\treturn rest == \"\" || rest[0] == '/'\n\t}\n\t// host+path 前缀匹配\n\treturn strings.HasPrefix(normalized, pattern)\n}\n\n// normalizeBaseURLForManifest 将 baseURL 规范化为 host 或 host+path 形式用于匹配。\n// 去掉 scheme(https://)、尾部 /、# 标记、版本段(/v1 等)。\nfunc normalizeBaseURLForManifest(baseURL string) string {\n\ts := strings.ToLower(strings.TrimSpace(baseURL))\n\tif s == \"\" {\n\t\treturn \"\"\n\t}\n\t// 去掉 scheme\n\tif idx := strings.Index(s, \"://\"); idx >= 0 {\n\t\ts = s[idx+3:]\n\t}\n\t// 去掉 #\n\ts = strings.TrimSuffix(s, \"#\")\n\t// 去掉尾部 /\n\ts = strings.TrimRight(s, \"/\")\n\treturn s\n}\n"}, "bd0b97490c8312ee2376346013fec2f4703ebc7a": {"t": 1785759339.0765438, "n": 4452, "h": "3b26593e7546328a", "c": "package config\n\nimport (\n\t\"strings\"\n\n\t\"github.com/BenedictKing/ccx/internal/presetstore\"\n)\n\n// BuiltinModelsManifest 内置模型清单条目。\n// 对部分官方订阅入口(如 Claude OAuth 订阅、Codex plan 入口),\n// 上游 models 接口可能不存在(404)或返回错误清单。\n// 命中时直接使用 ModelIDs 作为该入口的可用模型列表,跳过上游探测。\ntype BuiltinModelsManifest struct {\n\t// BaseURLPattern 精确 host 或 host+path 前缀匹配,\n\t// 如 \"api.anthropic.com\" 或 \"api.anthropic.com/v1\"。\n\tBaseURLPattern string `json:\"baseUrlPattern\"`\n\n\t// ServiceType 渠道协议类型:messages/responses/chat/gemini/images/vectors。\n\tServiceType string `json:\"serviceType\"`\n\n\t// PlanHint 订阅类型提示,仅用于文档和日志,不影响匹配逻辑。\n\tPlanHint string `json:\"planHint,omitempty\"`\n\n\t// ModelIDs 该入口实际可用的模型 ID 清单。\n\tModelIDs []string `json:\"modelIds\"`\n\n\t// DisableProbe 为 true 时 Discovery 流程跳过 GET /v1/models,\n\t// 直接用 ModelIDs 生成 ModelProfile[]。\n\tDisableProbe bool `json:\"disableProbe\"`\n}\n\n// builtinModelsManifests 内置模型清单。\n// 当前仅收录从仓库代码/模板/测试中能确认的官方入口。\n// 新增条目须附注证据来源,不臆造 baseURL。\nvar builtinModelsManifests = []BuiltinModelsManifest{\n\t// Anthropic 官方 API 入口(来源:buildClaudeCompatibleModelsURLs 测试、\n\t// expectedRequestUrls 测试、前端 locale placeholder 确认 api.anthropic.com)。\n\t// 该入口的 /v1/models 正常可用,DisableProbe=false;\n\t// 当上游探测失败时回退使用此清单。\n\t{\n\t\tBaseURLPattern: \"api.anthropic.com\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"anthropic_api\",\n\t\tModelIDs: []string{\n\t\t\t\"claude-fable-5\",\n\t\t\t\"claude-mythos-5\",\n\t\t\t\"claude-opus-4-8\",\n\t\t\t\"claude-opus-4-7\",\n\t\t\t\"claude-opus-4-6\",\n\t\t\t\"claude-sonnet-5\",\n\t\t\t\"claude-sonnet-4-6\",\n\t\t\t\"claude-sonnet-4-5\",\n\t\t\t\"claude-opus-4-5\",\n\t\t\t\"claude-haiku-4-5\",\n\t\t},\n\t\tDisableProbe: false,\n\t},\n}\n\n// LookupBuiltinManifest 根据上游 baseURL 和 serviceType 查找匹配的内置清单。\n// 匹配规则:BaseURLPattern 作为 host 或 host+path 前缀匹配。\n// 优先精确 host 匹配,其次 host+path 前缀匹配(最长前缀优先)。\n// 返回匹配的清单和 true;未命中返回零值和 false。\nfunc LookupBuiltinManifest(baseURL string, serviceType string) (BuiltinModelsManifest, bool) {\n\tnormalized := normalizeBaseURLForManifest(baseURL)\n\tif normalized == \"\" {\n\t\treturn BuiltinModelsManifest{}, false\n\t}\n\n\tif manifest, found := lookupBuiltinManifestIn(runtimeBuiltinModelsManifests(), normalized, serviceType); found {\n\t\treturn manifest, true\n\t}\n\treturn lookupBuiltinManifestIn(builtinModelsManifests, normalized, serviceType)\n}\n\nfunc runtimeBuiltinModelsManifests() []BuiltinModelsManifest {\n\tbundle := presetstore.Default().Get()\n\tif bundle == nil || bundle.BuiltinModelsManifests == nil || len(bundle.BuiltinModelsManifests.Manifests) == 0 {\n\t\treturn nil\n\t}\n\tmanifests := make([]BuiltinModelsManifest, 0, len(bundle.BuiltinModelsManifests.Manifests))\n\tfor _, entry := range bundle.BuiltinModelsManifests.Manifests {\n\t\tmanifests = append(manifests, BuiltinModelsManifest{\n\t\t\tBaseURLPattern: entry.BaseURLPattern,\n\t\t\tServiceType: entry.ServiceType,\n\t\t\tPlanHint: entry.PlanHint,\n\t\t\tModelIDs: append([]string(nil), entry.ModelIDs...),\n\t\t\tDisableProbe: entry.DisableProbe,\n\t\t})\n\t}\n\treturn manifests\n}\n\nfunc lookupBuiltinManifestIn(manifests []BuiltinModelsManifest, normalized string, serviceType string) (BuiltinModelsManifest, bool) {\n\tif len(manifests) == 0 {\n\t\treturn BuiltinModelsManifest{}, false\n\t}\n\tvar bestMatch BuiltinModelsManifest\n\tvar bestMatchLen int\n\tfound := false\n\tfor _, manifest := range manifests {\n\t\tif manifest.ServiceType != serviceType {\n\t\t\tcontinue\n\t\t}\n\t\tpattern := strings.ToLower(manifest.BaseURLPattern)\n\t\tif !matchManifestPattern(normalized, pattern) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(pattern) > bestMatchLen {\n\t\t\tbestMatch = manifest\n\t\t\tbestMatchLen = len(pattern)\n\t\t\tfound = true\n\t\t}\n\t}\n\treturn bestMatch, found\n}\n\n// matchManifestPattern 检查 normalized baseURL 是否匹配 manifest pattern。\n// pattern 可以是纯 host(精确匹配)或 host+path 前缀匹配。\nfunc matchManifestPattern(normalized string, pattern string) bool {\n\t// 精确匹配\n\tif normalized == pattern {\n\t\treturn true\n\t}\n\t// host 精确匹配(pattern 不含路径,normalized 可能含路径)\n\tif !strings.Contains(pattern, \"/\") && strings.HasPrefix(normalized, pattern) {\n\t\trest := normalized[len(pattern):]\n\t\treturn rest == \"\" || rest[0] == '/'\n\t}\n\t// host+path 前缀匹配\n\treturn strings.HasPrefix(normalized, pattern)\n}\n\n// normalizeBaseURLForManifest 将 baseURL 规范化为 host 或 host+path 形式用于匹配。\n// 去掉 scheme(https://)、尾部 /、# 标记、版本段(/v1 等)。\nfunc normalizeBaseURLForManifest(baseURL string) string {\n\ts := strings.ToLower(strings.TrimSpace(baseURL))\n\tif s == \"\" {\n\t\treturn \"\"\n\t}\n\t// 去掉 scheme\n\tif idx := strings.Index(s, \"://\"); idx >= 0 {\n\t\ts = s[idx+3:]\n\t}\n\t// 去掉 #\n\ts = strings.TrimSuffix(s, \"#\")\n\t// 去掉尾部 /\n\ts = strings.TrimRight(s, \"/\")\n\treturn s\n}\n"}, "e0c41e754840d8faeeb11247fd5cb947e053e2a7": {"t": 1785759341.6297235, "n": 3428, "h": "51ac09e2d4d4459a", "c": "package config\n\nimport \"strings\"\n\n// BuiltinModelsManifest 内置模型清单条目。\n// 对部分官方订阅入口(如 Claude OAuth 订阅、Codex plan 入口),\n// 上游 models 接口可能不存在(404)或返回错误清单。\n// 命中时直接使用 ModelIDs 作为该入口的可用模型列表,跳过上游探测。\ntype BuiltinModelsManifest struct {\n\t// BaseURLPattern 精确 host 或 host+path 前缀匹配,\n\t// 如 \"api.anthropic.com\" 或 \"api.anthropic.com/v1\"。\n\tBaseURLPattern string `json:\"baseUrlPattern\"`\n\n\t// ServiceType 渠道协议类型:messages/responses/chat/gemini/images/vectors。\n\tServiceType string `json:\"serviceType\"`\n\n\t// PlanHint 订阅类型提示,仅用于文档和日志,不影响匹配逻辑。\n\tPlanHint string `json:\"planHint,omitempty\"`\n\n\t// ModelIDs 该入口实际可用的模型 ID 清单。\n\tModelIDs []string `json:\"modelIds\"`\n\n\t// DisableProbe 为 true 时 Discovery 流程跳过 GET /v1/models,\n\t// 直接用 ModelIDs 生成 ModelProfile[]。\n\tDisableProbe bool `json:\"disableProbe\"`\n}\n\n// builtinModelsManifests 内置模型清单。\n// 当前仅收录从仓库代码/模板/测试中能确认的官方入口。\n// 新增条目须附注证据来源,不臆造 baseURL。\nvar builtinModelsManifests = []BuiltinModelsManifest{\n\t// Anthropic 官方 API 入口(来源:buildClaudeCompatibleModelsURLs 测试、\n\t// expectedRequestUrls 测试、前端 locale placeholder 确认 api.anthropic.com)。\n\t// 该入口的 /v1/models 正常可用,DisableProbe=false;\n\t// 当上游探测失败时回退使用此清单。\n\t{\n\t\tBaseURLPattern: \"api.anthropic.com\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"anthropic_api\",\n\t\tModelIDs: []string{\n\t\t\t\"claude-fable-5\",\n\t\t\t\"claude-mythos-5\",\n\t\t\t\"claude-opus-4-8\",\n\t\t\t\"claude-opus-4-7\",\n\t\t\t\"claude-opus-4-6\",\n\t\t\t\"claude-sonnet-5\",\n\t\t\t\"claude-sonnet-4-6\",\n\t\t\t\"claude-sonnet-4-5\",\n\t\t\t\"claude-opus-4-5\",\n\t\t\t\"claude-haiku-4-5\",\n\t\t},\n\t\tDisableProbe: false,\n\t},\n}\n\n// LookupBuiltinManifest 根据上游 baseURL 和 serviceType 查找匹配的内置清单。\n// 匹配规则:BaseURLPattern 作为 host 或 host+path 前缀匹配。\n// 优先精确 host 匹配,其次 host+path 前缀匹配(最长前缀优先)。\n// 返回匹配的清单和 true;未命中返回零值和 false。\nfunc LookupBuiltinManifest(baseURL string, serviceType string) (BuiltinModelsManifest, bool) {\n\tif len(builtinModelsManifests) == 0 {\n\t\treturn BuiltinModelsManifest{}, false\n\t}\n\n\tnormalized := normalizeBaseURLForManifest(baseURL)\n\tif normalized == \"\" {\n\t\treturn BuiltinModelsManifest{}, false\n\t}\n\n\tvar bestMatch BuiltinModelsManifest\n\tvar bestMatchLen int\n\tfound := false\n\n\tfor _, manifest := range builtinModelsManifests {\n\t\tif manifest.ServiceType != serviceType {\n\t\t\tcontinue\n\t\t}\n\t\tpattern := strings.ToLower(manifest.BaseURLPattern)\n\t\tif !matchManifestPattern(normalized, pattern) {\n\t\t\tcontinue\n\t\t}\n\t\t// 最长前缀优先:避免 \"api.anthropic.com\" 误匹配到更具体的 pattern\n\t\tif len(pattern) > bestMatchLen {\n\t\t\tbestMatch = manifest\n\t\t\tbestMatchLen = len(pattern)\n\t\t\tfound = true\n\t\t}\n\t}\n\n\treturn bestMatch, found\n}\n\n// matchManifestPattern 检查 normalized baseURL 是否匹配 manifest pattern。\n// pattern 可以是纯 host(精确匹配)或 host+path 前缀匹配。\nfunc matchManifestPattern(normalized string, pattern string) bool {\n\t// 精确匹配\n\tif normalized == pattern {\n\t\treturn true\n\t}\n\t// host 精确匹配(pattern 不含路径,normalized 可能含路径)\n\tif !strings.Contains(pattern, \"/\") && strings.HasPrefix(normalized, pattern) {\n\t\trest := normalized[len(pattern):]\n\t\treturn rest == \"\" || rest[0] == '/'\n\t}\n\t// host+path 前缀匹配\n\treturn strings.HasPrefix(normalized, pattern)\n}\n\n// normalizeBaseURLForManifest 将 baseURL 规范化为 host 或 host+path 形式用于匹配。\n// 去掉 scheme(https://)、尾部 /、# 标记、版本段(/v1 等)。\nfunc normalizeBaseURLForManifest(baseURL string) string {\n\ts := strings.ToLower(strings.TrimSpace(baseURL))\n\tif s == \"\" {\n\t\treturn \"\"\n\t}\n\t// 去掉 scheme\n\tif idx := strings.Index(s, \"://\"); idx >= 0 {\n\t\ts = s[idx+3:]\n\t}\n\t// 去掉 #\n\ts = strings.TrimSuffix(s, \"#\")\n\t// 去掉尾部 /\n\ts = strings.TrimRight(s, \"/\")\n\treturn s\n}\n"}, "71b842e3e8300d0b9329446af03a39c498580b55": {"t": 1786067578.5158818, "n": 11921, "h": "10bedd911c7ff8be", "c": "package config\n\nimport (\n\t\"net/url\"\n\t\"strings\"\n\n\t\"github.com/BenedictKing/ccx/internal/presetstore\"\n)\n\n// BuiltinModelsManifest 内置模型清单条目。\n// 对部分官方订阅入口(如 Claude OAuth 订阅、Codex plan 入口),\n// 上游 models 接口可能不存在(404)或返回错误清单。\n// 命中时直接使用 ModelIDs 作为该入口的可用模型列表,跳过上游探测。\ntype BuiltinModelsManifest struct {\n\t// BaseURLPattern 精确 host 或 host+path 前缀匹配,\n\t// 如 \"api.anthropic.com\" 或 \"api.anthropic.com/v1\"。\n\tBaseURLPattern string `json:\"baseUrlPattern\"`\n\n\t// ServiceType 运行时上游服务类型;claude 会归一为 messages,OpenAI Chat 兼容入口用 openai。\n\tServiceType string `json:\"serviceType\"`\n\n\t// PlanHint 订阅类型提示,仅用于文档和日志,不影响匹配逻辑。\n\tPlanHint string `json:\"planHint,omitempty\"`\n\n\t// ModelsURL 覆盖默认从 baseURL 拼接的模型列表地址。\n\t// 用于协议入口与官方模型列表入口不在同一路径下的 provider。\n\tModelsURL string `json:\"modelsUrl,omitempty\"`\n\n\t// ModelIDs 该入口实际可用的模型 ID 清单。\n\tModelIDs []string `json:\"modelIds\"`\n\n\t// ExcludeModelPatterns 从上游 /v1/models 返回中剔除的模型 ID 正则。\n\tExcludeModelPatterns []string `json:\"excludeModelPatterns,omitempty\"`\n\n\t// DisableProbe 为 true 时 Discovery 流程跳过 GET /v1/models,\n\t// 直接用 ModelIDs 生成 ModelProfile[]。\n\tDisableProbe bool `json:\"disableProbe\"`\n}\n\n// builtinModelsManifests 内置模型清单。\n// 当前仅收录从仓库代码/模板/测试中能确认的官方入口。\n// 新增条目须附注证据来源,不臆造 baseURL。\nvar builtinModelsManifests = []BuiltinModelsManifest{\n\t// Anthropic 官方 API 入口(来源:buildClaudeCompatibleModelsURLs 测试、\n\t// expectedRequestUrls 测试、前端 locale placeholder 确认 api.anthropic.com)。\n\t// 该入口的 /v1/models 正常可用,DisableProbe=false;\n\t// 当上游探测失败时回退使用此清单。\n\t{\n\t\tBaseURLPattern: \"api.anthropic.com\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"anthropic_api\",\n\t\tModelIDs: []string{\n\t\t\t\"claude-fable-5\",\n\t\t\t\"claude-mythos-5\",\n\t\t\t\"claude-opus-4-8\",\n\t\t\t\"claude-opus-4-7\",\n\t\t\t\"claude-opus-4-6\",\n\t\t\t\"claude-sonnet-5\",\n\t\t\t\"claude-sonnet-4-6\",\n\t\t\t\"claude-sonnet-4-5\",\n\t\t\t\"claude-opus-4-5\",\n\t\t\t\"claude-haiku-4-5\",\n\t\t},\n\t\tDisableProbe: false,\n\t},\n\t// DeepSeek 官方兼容入口(来源:provider_templates.go 与 DeepSeek API 文档)。\n\t// 两种协议统一通过官方 GET https://api.deepseek.com/models 获取模型清单;\n\t// 请求入口仍分别使用 /anthropic 与 OpenAI 兼容根地址。\n\t{\n\t\tBaseURLPattern: \"api.deepseek.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"deepseek_anthropic\",\n\t\tModelsURL: \"https://api.deepseek.com/models\",\n\t\tModelIDs: deepseekModelIDs(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"api.deepseek.com\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"deepseek_openai\",\n\t\tModelsURL: \"https://api.deepseek.com/models\",\n\t\tModelIDs: deepseekModelIDs(),\n\t\tDisableProbe: false,\n\t},\n\t// 小米 MiMo Anthropic 兼容入口(来源:provider_templates.go 与 docs/providers/mimo.md)。\n\t// Anthropic 协议入口可用性通过 /v1/messages 验证;/v1/models 不作为能力判定依据。\n\t{\n\t\tBaseURLPattern: \"api.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_payg_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-cn.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_cn_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_sgp_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-ams.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_ams_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t// 小米 MiMo OpenAI Chat 兼容入口(来源:provider_templates.go 与 docs/providers/mimo.md)。\n\t// /v1/models 可用于发现;返回 ASR/TTS 等非文本模型时通过 ExcludeModelPatterns 过滤。\n\t{\n\t\tBaseURLPattern: \"api.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_payg_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-cn.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_token_plan_cn_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-sgp.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_token_plan_sgp_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-ams.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_token_plan_ams_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t// Kimi Code 官方订阅入口(来源:Kimi Code 模型配置与概览文档)。\n\t// /coding/v1/models 会按 API Key 返回当前会员档位可用的模型;\n\t// ModelIDs 仅作为 models 端点不可用时的静态回退,只保留所有套餐都可用的基础模型。\n\t{\n\t\tBaseURLPattern: \"api.kimi.com/coding\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"kimi_code_anthropic\",\n\t\tModelsURL: \"https://api.kimi.com/coding/v1/models\",\n\t\tModelIDs: kimiCodeModelIDs(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"api.kimi.com/coding/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"kimi_code_openai\",\n\t\tModelsURL: \"https://api.kimi.com/coding/v1/models\",\n\t\tModelIDs: kimiCodeModelIDs(),\n\t\tDisableProbe: false,\n\t},\n\t// 火山方舟 Agent/Coding Plan 套餐入口(来源:provider_templates.go volcengine 模板)。\n\t// 套餐模型发现依赖火山云管控面签名接口,普通推理 Key 无法通过 /v1/models 探测;\n\t// 未绑定 Access Key 时用此清单兜底,让渠道立即可用。DisableProbe=true。\n\t// BaseURLPattern 用 host+path 前缀(不含版本段),以同时匹配 openai 入口的 /v3 后缀。\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/plan\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"volcengine_plan_anthropic\",\n\t\tModelIDs: volcengineAgentPlanModelIDs(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/coding\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"volcengine_coding_anthropic\",\n\t\tModelIDs: volcengineCodingPlanModelIDs(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/plan\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"volcengine_plan_openai\",\n\t\tModelIDs: volcengineAgentPlanModelIDs(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/coding\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"volcengine_coding_openai\",\n\t\tModelIDs: volcengineCodingPlanModelIDs(),\n\t\tDisableProbe: true,\n\t},\n}\n\nfunc mimoModelIDs() []string {\n\treturn []string{\n\t\t\"mimo-v2.5-pro\",\n\t\t\"mimo-v2.5\",\n\t}\n}\n\nfunc kimiCodeModelIDs() []string {\n\treturn []string{\n\t\t\"kimi-for-coding\",\n\t}\n}\n\nfunc deepseekModelIDs() []string {\n\treturn []string{\n\t\t\"deepseek-v4-pro\",\n\t\t\"deepseek-v4-flash\",\n\t}\n}\n\n// volcengineAgentPlanModelIDs 火山方舟 Agent Plan(/api/plan) 入口的兜底文本模型清单。\n// 当用户未绑定火山云 Access Key(无法调用管控面模型发现接口)时,\n// 用此清单让渠道立即可用;绑定 Access Key 后由 FetchModels 覆盖为真实清单。\n// 清单来源:火山方舟 Agent Plan 套餐概览(2026-07),与 Coding Plan 略有差异。\nfunc volcengineAgentPlanModelIDs() []string {\n\treturn []string{\n\t\t\"doubao-seed-2.0-code\",\n\t\t\"doubao-seed-2.0-pro\",\n\t\t\"doubao-seed-2.0-lite\",\n\t\t\"doubao-seed-2.0-mini\",\n\t\t\"minimax-m2.7\",\n\t\t\"minimax-m3\",\n\t\t\"glm-5.2\",\n\t\t\"glm-latest\",\n\t\t\"deepseek-v4-flash\",\n\t\t\"deepseek-v4-pro\",\n\t\t\"kimi-k3\",\n\t\t\"kimi-k2.6\",\n\t\t\"kimi-k2.7-code\",\n\t}\n}\n\n// volcengineCodingPlanModelIDs 火山方舟 Coding Plan(/api/coding) 入口的兜底文本模型清单。\n// 与 Agent Plan 差异:Coding Plan 独有 doubao-seed-code,不含 doubao-seed-2.0-mini/glm-latest。\n// 清单来源:火山方舟 Coding Plan 套餐概览(2026-07)。\nfunc volcengineCodingPlanModelIDs() []string {\n\treturn []string{\n\t\t\"doubao-seed-2.0-code\",\n\t\t\"doubao-seed-2.0-pro\",\n\t\t\"doubao-seed-2.0-lite\",\n\t\t\"doubao-seed-code\",\n\t\t\"minimax-m2.7\",\n\t\t\"minimax-m3\",\n\t\t\"glm-5.2\",\n\t\t\"deepseek-v4-flash\",\n\t\t\"deepseek-v4-pro\",\n\t\t\"kimi-k2.6\",\n\t\t\"kimi-k2.7-code\",\n\t}\n}\n\nfunc mimoExcludeModelPatterns() []string {\n\treturn []string{\n\t\t`^mimo-v2\\.5-(?:asr|tts(?:-.+)?)$`,\n\t}\n}\n\n// LookupBuiltinManifest 根据上游 baseURL 和 serviceType 查找匹配的内置清单。\n// 匹配规则:BaseURLPattern 作为 host 或 host+path 前缀匹配。\n// 优先精确 host 匹配,其次 host+path 前缀匹配(最长前缀优先)。\n// 返回匹配的清单和 true;未命中返回零值和 false。\nfunc LookupBuiltinManifest(baseURL string, serviceType string) (BuiltinModelsManifest, bool) {\n\tnormalized := normalizeBaseURLForManifest(baseURL)\n\tif normalized == \"\" {\n\t\treturn BuiltinModelsManifest{}, false\n\t}\n\n\tif manifest, found := lookupBuiltinManifestIn(runtimeBuiltinModelsManifests(), normalized, serviceType); found {\n\t\treturn manifest, true\n\t}\n\treturn lookupBuiltinManifestIn(builtinModelsManifests, normalized, serviceType)\n}\n\n// ResolveBuiltinModelsURL 返回清单声明的模型列表地址。\n// 为避免把 API Key 发送到第三方主机,覆盖地址必须使用 HTTPS 且与渠道 baseURL 同主机。\nfunc ResolveBuiltinModelsURL(baseURL string, serviceType string) (string, bool) {\n\tmanifest, ok := LookupBuiltinManifest(baseURL, strings.ToLower(strings.TrimSpace(serviceType)))\n\tif !ok || strings.TrimSpace(manifest.ModelsURL) == \"\" {\n\t\treturn \"\", false\n\t}\n\tbase, baseErr := url.Parse(strings.TrimSuffix(strings.TrimSpace(baseURL), \"#\"))\n\tmodels, modelsErr := url.Parse(strings.TrimSpace(manifest.ModelsURL))\n\tif baseErr != nil || modelsErr != nil || !strings.EqualFold(models.Scheme, \"https\") ||\n\t\tbase.Hostname() == \"\" || models.User != nil || models.Fragment != \"\" || !sameURLServer(base, models) {\n\t\treturn \"\", false\n\t}\n\treturn models.String(), true\n}\n\nfunc runtimeBuiltinModelsManifests() []BuiltinModelsManifest {\n\tbundle := presetstore.Default().Get()\n\tif bundle == nil || bundle.BuiltinModelsManifests == nil || len(bundle.BuiltinModelsManifests.Manifests) == 0 {\n\t\treturn nil\n\t}\n\tmanifests := make([]BuiltinModelsManifest, 0, len(bundle.BuiltinModelsManifests.Manifests))\n\tfor _, entry := range bundle.BuiltinModelsManifests.Manifests {\n\t\tmanifests = append(manifests, BuiltinModelsManifest{\n\t\t\tBaseURLPattern: entry.BaseURLPattern,\n\t\t\tServiceType: entry.ServiceType,\n\t\t\tPlanHint: entry.PlanHint,\n\t\t\tModelsURL: entry.ModelsURL,\n\t\t\tModelIDs: append([]string(nil), entry.ModelIDs...),\n\t\t\tExcludeModelPatterns: append([]string(nil), entry.ExcludeModelPatterns...),\n\t\t\tDisableProbe: entry.DisableProbe,\n\t\t})\n\t}\n\treturn manifests\n}\n\nfunc lookupBuiltinManifestIn(manifests []BuiltinModelsManifest, normalized string, serviceType string) (BuiltinModelsManifest, bool) {\n\tif len(manifests) == 0 {\n\t\treturn BuiltinModelsManifest{}, false\n\t}\n\tvar bestMatch BuiltinModelsManifest\n\tvar bestMatchLen int\n\tfound := false\n\tfor _, manifest := range manifests {\n\t\tif manifest.ServiceType != serviceType {\n\t\t\tcontinue\n\t\t}\n\t\tpattern := strings.ToLower(manifest.BaseURLPattern)\n\t\tif !matchManifestPattern(normalized, pattern) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(pattern) > bestMatchLen {\n\t\t\tbestMatch = manifest\n\t\t\tbestMatchLen = len(pattern)\n\t\t\tfound = true\n\t\t}\n\t}\n\treturn bestMatch, found\n}\n\n// matchManifestPattern 检查 normalized baseURL 是否匹配 manifest pattern。\n// pattern 可以是纯 host(精确匹配)或 host+path 前缀匹配。\nfunc matchManifestPattern(normalized string, pattern string) bool {\n\t// 精确匹配\n\tif normalized == pattern {\n\t\treturn true\n\t}\n\t// host 精确匹配(pattern 不含路径,normalized 可能含路径)\n\tif !strings.Contains(pattern, \"/\") && strings.HasPrefix(normalized, pattern) {\n\t\trest := normalized[len(pattern):]\n\t\treturn rest == \"\" || rest[0] == '/'\n\t}\n\t// host+path 前缀匹配\n\treturn strings.HasPrefix(normalized, pattern)\n}\n\n// normalizeBaseURLForManifest 将 baseURL 规范化为 host 或 host+path 形式用于匹配。\n// 去掉 scheme(https://)、尾部 /、# 标记、版本段(/v1 等)。\nfunc normalizeBaseURLForManifest(baseURL string) string {\n\ts := strings.ToLower(strings.TrimSpace(baseURL))\n\tif s == \"\" {\n\t\treturn \"\"\n\t}\n\t// 去掉 scheme\n\tif idx := strings.Index(s, \"://\"); idx >= 0 {\n\t\ts = s[idx+3:]\n\t}\n\t// 去掉 #\n\ts = strings.TrimSuffix(s, \"#\")\n\t// 去掉尾部 /\n\ts = strings.TrimRight(s, \"/\")\n\treturn s\n}\n"}, "8cb1f08da90e4858885f246ddadd488390fdba08": {"t": 1786068374.4191716, "n": 11921, "h": "10bedd911c7ff8be", "c": "package config\n\nimport (\n\t\"net/url\"\n\t\"strings\"\n\n\t\"github.com/BenedictKing/ccx/internal/presetstore\"\n)\n\n// BuiltinModelsManifest 内置模型清单条目。\n// 对部分官方订阅入口(如 Claude OAuth 订阅、Codex plan 入口),\n// 上游 models 接口可能不存在(404)或返回错误清单。\n// 命中时直接使用 ModelIDs 作为该入口的可用模型列表,跳过上游探测。\ntype BuiltinModelsManifest struct {\n\t// BaseURLPattern 精确 host 或 host+path 前缀匹配,\n\t// 如 \"api.anthropic.com\" 或 \"api.anthropic.com/v1\"。\n\tBaseURLPattern string `json:\"baseUrlPattern\"`\n\n\t// ServiceType 运行时上游服务类型;claude 会归一为 messages,OpenAI Chat 兼容入口用 openai。\n\tServiceType string `json:\"serviceType\"`\n\n\t// PlanHint 订阅类型提示,仅用于文档和日志,不影响匹配逻辑。\n\tPlanHint string `json:\"planHint,omitempty\"`\n\n\t// ModelsURL 覆盖默认从 baseURL 拼接的模型列表地址。\n\t// 用于协议入口与官方模型列表入口不在同一路径下的 provider。\n\tModelsURL string `json:\"modelsUrl,omitempty\"`\n\n\t// ModelIDs 该入口实际可用的模型 ID 清单。\n\tModelIDs []string `json:\"modelIds\"`\n\n\t// ExcludeModelPatterns 从上游 /v1/models 返回中剔除的模型 ID 正则。\n\tExcludeModelPatterns []string `json:\"excludeModelPatterns,omitempty\"`\n\n\t// DisableProbe 为 true 时 Discovery 流程跳过 GET /v1/models,\n\t// 直接用 ModelIDs 生成 ModelProfile[]。\n\tDisableProbe bool `json:\"disableProbe\"`\n}\n\n// builtinModelsManifests 内置模型清单。\n// 当前仅收录从仓库代码/模板/测试中能确认的官方入口。\n// 新增条目须附注证据来源,不臆造 baseURL。\nvar builtinModelsManifests = []BuiltinModelsManifest{\n\t// Anthropic 官方 API 入口(来源:buildClaudeCompatibleModelsURLs 测试、\n\t// expectedRequestUrls 测试、前端 locale placeholder 确认 api.anthropic.com)。\n\t// 该入口的 /v1/models 正常可用,DisableProbe=false;\n\t// 当上游探测失败时回退使用此清单。\n\t{\n\t\tBaseURLPattern: \"api.anthropic.com\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"anthropic_api\",\n\t\tModelIDs: []string{\n\t\t\t\"claude-fable-5\",\n\t\t\t\"claude-mythos-5\",\n\t\t\t\"claude-opus-4-8\",\n\t\t\t\"claude-opus-4-7\",\n\t\t\t\"claude-opus-4-6\",\n\t\t\t\"claude-sonnet-5\",\n\t\t\t\"claude-sonnet-4-6\",\n\t\t\t\"claude-sonnet-4-5\",\n\t\t\t\"claude-opus-4-5\",\n\t\t\t\"claude-haiku-4-5\",\n\t\t},\n\t\tDisableProbe: false,\n\t},\n\t// DeepSeek 官方兼容入口(来源:provider_templates.go 与 DeepSeek API 文档)。\n\t// 两种协议统一通过官方 GET https://api.deepseek.com/models 获取模型清单;\n\t// 请求入口仍分别使用 /anthropic 与 OpenAI 兼容根地址。\n\t{\n\t\tBaseURLPattern: \"api.deepseek.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"deepseek_anthropic\",\n\t\tModelsURL: \"https://api.deepseek.com/models\",\n\t\tModelIDs: deepseekModelIDs(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"api.deepseek.com\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"deepseek_openai\",\n\t\tModelsURL: \"https://api.deepseek.com/models\",\n\t\tModelIDs: deepseekModelIDs(),\n\t\tDisableProbe: false,\n\t},\n\t// 小米 MiMo Anthropic 兼容入口(来源:provider_templates.go 与 docs/providers/mimo.md)。\n\t// Anthropic 协议入口可用性通过 /v1/messages 验证;/v1/models 不作为能力判定依据。\n\t{\n\t\tBaseURLPattern: \"api.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_payg_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-cn.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_cn_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-sgp.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_sgp_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-ams.xiaomimimo.com/anthropic\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"mimo_token_plan_ams_anthropic\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: true,\n\t},\n\t// 小米 MiMo OpenAI Chat 兼容入口(来源:provider_templates.go 与 docs/providers/mimo.md)。\n\t// /v1/models 可用于发现;返回 ASR/TTS 等非文本模型时通过 ExcludeModelPatterns 过滤。\n\t{\n\t\tBaseURLPattern: \"api.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_payg_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-cn.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_token_plan_cn_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-sgp.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_token_plan_sgp_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"token-plan-ams.xiaomimimo.com/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"mimo_token_plan_ams_openai\",\n\t\tModelIDs: mimoModelIDs(),\n\t\tExcludeModelPatterns: mimoExcludeModelPatterns(),\n\t\tDisableProbe: false,\n\t},\n\t// Kimi Code 官方订阅入口(来源:Kimi Code 模型配置与概览文档)。\n\t// /coding/v1/models 会按 API Key 返回当前会员档位可用的模型;\n\t// ModelIDs 仅作为 models 端点不可用时的静态回退,只保留所有套餐都可用的基础模型。\n\t{\n\t\tBaseURLPattern: \"api.kimi.com/coding\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"kimi_code_anthropic\",\n\t\tModelsURL: \"https://api.kimi.com/coding/v1/models\",\n\t\tModelIDs: kimiCodeModelIDs(),\n\t\tDisableProbe: false,\n\t},\n\t{\n\t\tBaseURLPattern: \"api.kimi.com/coding/v1\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"kimi_code_openai\",\n\t\tModelsURL: \"https://api.kimi.com/coding/v1/models\",\n\t\tModelIDs: kimiCodeModelIDs(),\n\t\tDisableProbe: false,\n\t},\n\t// 火山方舟 Agent/Coding Plan 套餐入口(来源:provider_templates.go volcengine 模板)。\n\t// 套餐模型发现依赖火山云管控面签名接口,普通推理 Key 无法通过 /v1/models 探测;\n\t// 未绑定 Access Key 时用此清单兜底,让渠道立即可用。DisableProbe=true。\n\t// BaseURLPattern 用 host+path 前缀(不含版本段),以同时匹配 openai 入口的 /v3 后缀。\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/plan\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"volcengine_plan_anthropic\",\n\t\tModelIDs: volcengineAgentPlanModelIDs(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/coding\",\n\t\tServiceType: \"messages\",\n\t\tPlanHint: \"volcengine_coding_anthropic\",\n\t\tModelIDs: volcengineCodingPlanModelIDs(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/plan\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"volcengine_plan_openai\",\n\t\tModelIDs: volcengineAgentPlanModelIDs(),\n\t\tDisableProbe: true,\n\t},\n\t{\n\t\tBaseURLPattern: \"ark.cn-beijing.volces.com/api/coding\",\n\t\tServiceType: \"openai\",\n\t\tPlanHint: \"volcengine_coding_openai\",\n\t\tModelIDs: volcengineCodingPlanModelIDs(),\n\t\tDisableProbe: true,\n\t},\n}\n\nfunc mimoModelIDs() []string {\n\treturn []string{\n\t\t\"mimo-v2.5-pro\",\n\t\t\"mimo-v2.5\",\n\t}\n}\n\nfunc kimiCodeModelIDs() []string {\n\treturn []string{\n\t\t\"kimi-for-coding\",\n\t}\n}\n\nfunc deepseekModelIDs() []string {\n\treturn []string{\n\t\t\"deepseek-v4-pro\",\n\t\t\"deepseek-v4-flash\",\n\t}\n}\n\n// volcengineAgentPlanModelIDs 火山方舟 Agent Plan(/api/plan) 入口的兜底文本模型清单。\n// 当用户未绑定火山云 Access Key(无法调用管控面模型发现接口)时,\n// 用此清单让渠道立即可用;绑定 Access Key 后由 FetchModels 覆盖为真实清单。\n// 清单来源:火山方舟 Agent Plan 套餐概览(2026-07),与 Coding Plan 略有差异。\nfunc volcengineAgentPlanModelIDs() []string {\n\treturn []string{\n\t\t\"doubao-seed-2.0-code\",\n\t\t\"doubao-seed-2.0-pro\",\n\t\t\"doubao-seed-2.0-lite\",\n\t\t\"doubao-seed-2.0-mini\",\n\t\t\"minimax-m2.7\",\n\t\t\"minimax-m3\",\n\t\t\"glm-5.2\",\n\t\t\"glm-latest\",\n\t\t\"deepseek-v4-flash\",\n\t\t\"deepseek-v4-pro\",\n\t\t\"kimi-k3\",\n\t\t\"kimi-k2.6\",\n\t\t\"kimi-k2.7-code\",\n\t}\n}\n\n// volcengineCodingPlanModelIDs 火山方舟 Coding Plan(/api/coding) 入口的兜底文本模型清单。\n// 与 Agent Plan 差异:Coding Plan 独有 doubao-seed-code,不含 doubao-seed-2.0-mini/glm-latest。\n// 清单来源:火山方舟 Coding Plan 套餐概览(2026-07)。\nfunc volcengineCodingPlanModelIDs() []string {\n\treturn []string{\n\t\t\"doubao-seed-2.0-code\",\n\t\t\"doubao-seed-2.0-pro\",\n\t\t\"doubao-seed-2.0-lite\",\n\t\t\"doubao-seed-code\",\n\t\t\"minimax-m2.7\",\n\t\t\"minimax-m3\",\n\t\t\"glm-5.2\",\n\t\t\"deepseek-v4-flash\",\n\t\t\"deepseek-v4-pro\",\n\t\t\"kimi-k2.6\",\n\t\t\"kimi-k2.7-code\",\n\t}\n}\n\nfunc mimoExcludeModelPatterns() []string {\n\treturn []string{\n\t\t`^mimo-v2\\.5-(?:asr|tts(?:-.+)?)$`,\n\t}\n}\n\n// LookupBuiltinManifest 根据上游 baseURL 和 serviceType 查找匹配的内置清单。\n// 匹配规则:BaseURLPattern 作为 host 或 host+path 前缀匹配。\n// 优先精确 host 匹配,其次 host+path 前缀匹配(最长前缀优先)。\n// 返回匹配的清单和 true;未命中返回零值和 false。\nfunc LookupBuiltinManifest(baseURL string, serviceType string) (BuiltinModelsManifest, bool) {\n\tnormalized := normalizeBaseURLForManifest(baseURL)\n\tif normalized == \"\" {\n\t\treturn BuiltinModelsManifest{}, false\n\t}\n\n\tif manifest, found := lookupBuiltinManifestIn(runtimeBuiltinModelsManifests(), normalized, serviceType); found {\n\t\treturn manifest, true\n\t}\n\treturn lookupBuiltinManifestIn(builtinModelsManifests, normalized, serviceType)\n}\n\n// ResolveBuiltinModelsURL 返回清单声明的模型列表地址。\n// 为避免把 API Key 发送到第三方主机,覆盖地址必须使用 HTTPS 且与渠道 baseURL 同主机。\nfunc ResolveBuiltinModelsURL(baseURL string, serviceType string) (string, bool) {\n\tmanifest, ok := LookupBuiltinManifest(baseURL, strings.ToLower(strings.TrimSpace(serviceType)))\n\tif !ok || strings.TrimSpace(manifest.ModelsURL) == \"\" {\n\t\treturn \"\", false\n\t}\n\tbase, baseErr := url.Parse(strings.TrimSuffix(strings.TrimSpace(baseURL), \"#\"))\n\tmodels, modelsErr := url.Parse(strings.TrimSpace(manifest.ModelsURL))\n\tif baseErr != nil || modelsErr != nil || !strings.EqualFold(models.Scheme, \"https\") ||\n\t\tbase.Hostname() == \"\" || models.User != nil || models.Fragment != \"\" || !sameURLServer(base, models) {\n\t\treturn \"\", false\n\t}\n\treturn models.String(), true\n}\n\nfunc runtimeBuiltinModelsManifests() []BuiltinModelsManifest {\n\tbundle := presetstore.Default().Get()\n\tif bundle == nil || bundle.BuiltinModelsManifests == nil || len(bundle.BuiltinModelsManifests.Manifests) == 0 {\n\t\treturn nil\n\t}\n\tmanifests := make([]BuiltinModelsManifest, 0, len(bundle.BuiltinModelsManifests.Manifests))\n\tfor _, entry := range bundle.BuiltinModelsManifests.Manifests {\n\t\tmanifests = append(manifests, BuiltinModelsManifest{\n\t\t\tBaseURLPattern: entry.BaseURLPattern,\n\t\t\tServiceType: entry.ServiceType,\n\t\t\tPlanHint: entry.PlanHint,\n\t\t\tModelsURL: entry.ModelsURL,\n\t\t\tModelIDs: append([]string(nil), entry.ModelIDs...),\n\t\t\tExcludeModelPatterns: append([]string(nil), entry.ExcludeModelPatterns...),\n\t\t\tDisableProbe: entry.DisableProbe,\n\t\t})\n\t}\n\treturn manifests\n}\n\nfunc lookupBuiltinManifestIn(manifests []BuiltinModelsManifest, normalized string, serviceType string) (BuiltinModelsManifest, bool) {\n\tif len(manifests) == 0 {\n\t\treturn BuiltinModelsManifest{}, false\n\t}\n\tvar bestMatch BuiltinModelsManifest\n\tvar bestMatchLen int\n\tfound := false\n\tfor _, manifest := range manifests {\n\t\tif manifest.ServiceType != serviceType {\n\t\t\tcontinue\n\t\t}\n\t\tpattern := strings.ToLower(manifest.BaseURLPattern)\n\t\tif !matchManifestPattern(normalized, pattern) {\n\t\t\tcontinue\n\t\t}\n\t\tif len(pattern) > bestMatchLen {\n\t\t\tbestMatch = manifest\n\t\t\tbestMatchLen = len(pattern)\n\t\t\tfound = true\n\t\t}\n\t}\n\treturn bestMatch, found\n}\n\n// matchManifestPattern 检查 normalized baseURL 是否匹配 manifest pattern。\n// pattern 可以是纯 host(精确匹配)或 host+path 前缀匹配。\nfunc matchManifestPattern(normalized string, pattern string) bool {\n\t// 精确匹配\n\tif normalized == pattern {\n\t\treturn true\n\t}\n\t// host 精确匹配(pattern 不含路径,normalized 可能含路径)\n\tif !strings.Contains(pattern, \"/\") && strings.HasPrefix(normalized, pattern) {\n\t\trest := normalized[len(pattern):]\n\t\treturn rest == \"\" || rest[0] == '/'\n\t}\n\t// host+path 前缀匹配\n\treturn strings.HasPrefix(normalized, pattern)\n}\n\n// normalizeBaseURLForManifest 将 baseURL 规范化为 host 或 host+path 形式用于匹配。\n// 去掉 scheme(https://)、尾部 /、# 标记、版本段(/v1 等)。\nfunc normalizeBaseURLForManifest(baseURL string) string {\n\ts := strings.ToLower(strings.TrimSpace(baseURL))\n\tif s == \"\" {\n\t\treturn \"\"\n\t}\n\t// 去掉 scheme\n\tif idx := strings.Index(s, \"://\"); idx >= 0 {\n\t\ts = s[idx+3:]\n\t}\n\t// 去掉 #\n\ts = strings.TrimSuffix(s, \"#\")\n\t// 去掉尾部 /\n\ts = strings.TrimRight(s, \"/\")\n\treturn s\n}\n"}}, "backend-go/internal/config/provider_templates_test.go": {"48488333f5455eef9b0999cee1eaf5bb63dd23d6": {"t": 1785746104.256761, "n": 13684, "h": "9b9bc1ff2db4a422", "c": "package config\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"slices\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestCandidatesForKeyPrefix(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"mimo\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 mimo 模板\")\n\t}\n\n\t// sk- 前缀 → payg 优先\n\tskCands := tmpl.CandidatesForKey(\"sk-abc\")\n\tif len(skCands) == 0 || skCands[0].PlanTag != \"payg\" {\n\t\tt.Errorf(\"sk- key 首选应为 payg,实际 %+v\", skCands)\n\t}\n\n\t// tp- 前缀 → token_plan 优先,且按 Priority(cn=0 先)\n\ttpCands := tmpl.CandidatesForKey(\"tp-xyz\")\n\tif len(tpCands) == 0 || tpCands[0].PlanTag != \"token_plan\" || tpCands[0].Region != \"cn\" {\n\t\tt.Errorf(\"tp- key 首选应为 token_plan/cn,实际 %+v\", tpCands)\n\t}\n\t// tp- 回退候选应包含 payg(全部候选都在)\n\tif len(tpCands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"tp- key 应返回全部候选(含回退)%d,实际 %d\", len(tmpl.Candidates), len(tpCands))\n\t}\n\n\t// 无匹配前缀 → 返回全部候选\n\tunknownCands := tmpl.CandidatesForKey(\"xx-none\")\n\tif len(unknownCands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"未匹配前缀应返回全部候选 %d,实际 %d\", len(tmpl.Candidates), len(unknownCands))\n\t}\n}\n\nfunc TestProviderTemplateMiMoRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"mimo\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 mimo 模板\")\n\t}\n\troutes := tmpl.AutoAddRoutes()\n\tif len(routes) != 4 {\n\t\tt.Fatalf(\"mimo 应创建 4 条 route,实际 %d: %+v\", len(routes), routes)\n\t}\n\n\twant := map[string]struct {\n\t\tserviceType string\n\t\tbaseSuffix string\n\t}{\n\t\t\"messages\": {serviceType: \"claude\", baseSuffix: \"/anthropic\"},\n\t\t\"chat\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t\t\"responses\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t\t\"gemini\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t}\n\tfor _, route := range routes {\n\t\texpect, ok := want[route.ChannelKind]\n\t\tif !ok {\n\t\t\tt.Fatalf(\"未知 route kind: %+v\", route)\n\t\t}\n\t\tif route.ServiceType != expect.serviceType {\n\t\t\tt.Fatalf(\"route %s serviceType=%s, want %s\", route.ChannelKind, route.ServiceType, expect.serviceType)\n\t\t}\n\t\tcandidates := tmpl.CandidatesForRouteKey(route, \"tp-test\")\n\t\tif len(candidates) == 0 {\n\t\t\tt.Fatalf(\"route %s 没有候选\", route.ChannelKind)\n\t\t}\n\t\tif !strings.HasSuffix(candidates[0].BaseURL, expect.baseSuffix) {\n\t\t\tt.Fatalf(\"route %s 首选 baseURL=%s, want suffix %s\", route.ChannelKind, candidates[0].BaseURL, expect.baseSuffix)\n\t\t}\n\t}\n\tif !tmpl.SupportsChannelKind(\"messages\") || !tmpl.SupportsChannelKind(\"chat\") || !tmpl.SupportsChannelKind(\"responses\") || !tmpl.SupportsChannelKind(\"gemini\") {\n\t\tt.Fatalf(\"mimo 应支持 messages/chat/responses/gemini routes: %+v\", routes)\n\t}\n}\n\nfunc TestCandidatesForKeyNoPrefixRules(t *testing.T) {\n\t// deepseek 无前缀规则,任意 key 返回全部候选\n\ttmpl, ok := GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 deepseek 模板\")\n\t}\n\tcands := tmpl.CandidatesForKey(\"anything\")\n\tif len(cands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"无前缀规则应返回全部候选 %d,实际 %d\", len(tmpl.Candidates), len(cands))\n\t}\n}\n\nfunc TestProviderTemplateDeepSeekRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 deepseek 模板\")\n\t}\n\troutes := tmpl.AutoAddRoutes()\n\tif len(routes) != 3 {\n\t\tt.Fatalf(\"deepseek 应创建 messages/chat/responses 三条 route,实际 %d: %+v\", len(routes), routes)\n\t}\n\twant := map[string]struct {\n\t\tserviceType string\n\t\tbaseURL string\n\t\tsupportedModels []string\n\t}{\n\t\t\"messages\": {serviceType: \"claude\", baseURL: \"https://api.deepseek.com/anthropic\"},\n\t\t\"chat\": {serviceType: \"openai\", baseURL: \"https://api.deepseek.com\"},\n\t\t// Responses 走官方原生入口,且仅 deepseek-v4-flash 可用\n\t\t\"responses\": {\n\t\t\tserviceType: \"responses\",\n\t\t\tbaseURL: \"https://api.deepseek.com/v1/responses\",\n\t\t\tsupportedModels: []string{\"deepseek-v4-flash\"},\n\t\t},\n\t}\n\tfor _, route := range routes {\n\t\texpect, found := want[route.ChannelKind]\n\t\tif !found || route.ServiceType != expect.serviceType || len(route.Candidates) != 1 || route.Candidates[0].BaseURL != expect.baseURL {\n\t\t\tt.Fatalf(\"DeepSeek route 不符合预期: %+v\", route)\n\t\t}\n\t\tif !slices.Equal(route.SupportedModels, expect.supportedModels) {\n\t\t\tt.Fatalf(\"DeepSeek route %s 模型白名单不符合预期: got %v, want %v\",\n\t\t\t\troute.ChannelKind, route.SupportedModels, expect.supportedModels)\n\t\t}\n\t}\n}\n\nfunc TestProviderTemplateGLMRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"glm\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 GLM 模板\")\n\t}\n\troutes := tmpl.AutoAddRoutes()\n\tif len(routes) != 3 {\n\t\tt.Fatalf(\"GLM 应创建 messages/chat/responses 三条 route,实际 %d: %+v\", len(routes), routes)\n\t}\n\twant := map[string]struct {\n\t\tserviceType string\n\t\tbaseURL string\n\t\tcandidates int\n\t}{\n\t\t\"messages\": {serviceType: \"claude\", baseURL: \"https://open.bigmodel.cn/api/anthropic\", candidates: 1},\n\t\t\"chat\": {serviceType: \"openai\", baseURL: \"https://open.bigmodel.cn/api/paas/v4#\", candidates: 2},\n\t\t\"responses\": {serviceType: \"openai\", baseURL: \"https://open.bigmodel.cn/api/paas/v4#\", candidates: 2},\n\t}\n\tfor _, route := range routes {\n\t\texpect, found := want[route.ChannelKind]\n\t\tif !found || route.ServiceType != expect.serviceType || len(route.Candidates) != expect.candidates || route.Candidates[0].BaseURL != expect.baseURL {\n\t\t\tt.Fatalf(\"GLM route 不符合预期: %+v\", route)\n\t\t}\n\t\tif route.Candidates[0].PlanTag != \"payg\" {\n\t\t\tt.Fatalf(\"GLM route 应标记为 payg: %+v\", route.Candidates[0])\n\t\t}\n\t\tif route.ChannelKind != \"messages\" && (route.Candidates[1].PlanTag != \"coding_plan\" || !strings.Contains(route.Candidates[1].BaseURL, \"/api/coding/\")) {\n\t\t\tt.Fatalf(\"GLM %s route 应包含 Coding Plan 候选: %+v\", route.ChannelKind, route.Candidates)\n\t\t}\n\t}\n}\n\nfunc TestInferProviderIDFromBaseURL(t *testing.T) {\n\ttests := []struct {\n\t\tbaseURL string\n\t\twant string\n\t\tok bool\n\t}{\n\t\t{baseURL: \"https://api.deepseek.com\", want: \"deepseek\", ok: true},\n\t\t{baseURL: \"https://api.deepseek.com/anthropic/v1\", want: \"deepseek\", ok: true},\n\t\t{baseURL: \"https://ark.cn-beijing.volces.com/api/plan/v3\", want: \"volcengine\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/anthropic\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/anthropic/v1/messages\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/paas/v4/\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/paas/v4/chat/completions\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://opencode.ai/zen/go/v1/chat/completions\", want: \"opencode-zen\", ok: true},\n\t\t{baseURL: \"https://opencode.ai/zen/v1\", want: \"opencode-zen\", ok: true},\n\t\t{baseURL: \"https://opencode.ai.evil.example/zen/v1\", ok: false},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/other\", ok: false},\n\t\t{baseURL: \"https://open.bigmodel.cn.evil.example/api/paas/v4\", ok: false},\n\t\t{baseURL: \"https://relay.example/v1\", ok: false},\n\t\t{baseURL: \"https://api.deepseek.com.evil.example\", ok: false},\n\t}\n\tfor _, tt := range tests {\n\t\tgot, ok := InferProviderIDFromBaseURL(tt.baseURL)\n\t\tif got != tt.want || ok != tt.ok {\n\t\t\tt.Fatalf(\"InferProviderIDFromBaseURL(%q) = %q, %v; want %q, %v\", tt.baseURL, got, ok, tt.want, tt.ok)\n\t\t}\n\t}\n}\n\nfunc TestInferProviderIDFromAPIKey(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tapiKey string\n\t\twant string\n\t\tok bool\n\t}{\n\t\t{name: \"zhipu id.secret\", apiKey: \"0123456789abcdef0123456789abcdef.ABCDEFGHIJKLMNO1\", want: \"glm\", ok: true},\n\t\t{name: \"trim spaces\", apiKey: \" 269abc123456789012345678.r8abcdef1234 \", want: \"glm\", ok: true},\n\t\t{name: \"shared sk prefix\", apiKey: \"sk-abcdefghijklmnopqrstuvwxyz123456\", ok: false},\n\t\t{name: \"short dotted token\", apiKey: \"abc.def\", ok: false},\n\t\t{name: \"jwt\", apiKey: \"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature\", ok: false},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, ok := InferProviderIDFromAPIKey(tt.apiKey)\n\t\t\tif got != tt.want || ok != tt.ok {\n\t\t\t\tt.Fatalf(\"InferProviderIDFromAPIKey() = %q, %v; want %q, %v\", got, ok, tt.want, tt.ok)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestProviderTemplateVolcenginePlanRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"volcengine\")\n\tif !ok {\n\t\tt.Fatal(\"未找到火山方舟模板\")\n\t}\n\tif tmpl.DisplayName != \"火山方舟 Agent/Coding Plan\" || len(tmpl.AutoAddRoutes()) != 4 {\n\t\tt.Fatalf(\"火山模板不完整: %+v\", tmpl)\n\t}\n\twantBases := map[string][2]string{\n\t\t\"messages\": {\"https://ark.cn-beijing.volces.com/api/plan\", \"https://ark.cn-beijing.volces.com/api/coding\"},\n\t\t\"chat\": {\"https://ark.cn-beijing.volces.com/api/plan/v3\", \"https://ark.cn-beijing.volces.com/api/coding/v3\"},\n\t\t\"responses\": {\"https://ark.cn-beijing.volces.com/api/plan/v3\", \"https://ark.cn-beijing.volces.com/api/coding/v3\"},\n\t\t\"gemini\": {\"https://ark.cn-beijing.volces.com/api/plan/v3\", \"https://ark.cn-beijing.volces.com/api/coding/v3\"},\n\t}\n\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\tcandidates := tmpl.CandidatesForRouteKey(route, \"ark-test\")\n\t\tif len(candidates) != 2 {\n\t\t\tt.Fatalf(\"route %s 应包含 Agent/Coding 两个候选: %+v\", route.ChannelKind, candidates)\n\t\t}\n\t\twant, exists := wantBases[route.ChannelKind]\n\t\tif !exists || candidates[0].BaseURL != want[0] || candidates[1].BaseURL != want[1] {\n\t\t\tt.Fatalf(\"route %s Base URL 错误: got=%q/%q want=%q/%q\", route.ChannelKind,\n\t\t\t\tcandidates[0].BaseURL, candidates[1].BaseURL, want[0], want[1])\n\t\t}\n\t}\n}\n\nfunc TestListAndGetProviderTemplate(t *testing.T) {\n\tall := ListProviderTemplates()\n\tif len(all) < 16 {\n\t\tt.Errorf(\"内置 provider 模板应至少为 16 个,实际 %d\", len(all))\n\t}\n\tfor _, id := range []string{\n\t\t\"mimo\", \"deepseek\", \"kimi\", \"glm\", \"volcengine\", \"compshare\", \"sensenova\", \"minimax\",\n\t\t\"dashscope\", \"opencode-zen\", \"tencent-lkeap\", \"qianfan\", \"xfyun\", \"openrouter\", \"modelscope\", \"originrouter\",\n\t} {\n\t\tif _, ok := GetProviderTemplate(id); !ok {\n\t\t\tt.Errorf(\"缺少 provider 模板: %s\", id)\n\t\t}\n\t}\n\tfor alias, canonical := range map[string]string{\n\t\t\" opencode-go \": \"opencode-zen\",\n\t\t\"VOLC-ARK\": \"volcengine\",\n\t\t\"kimi-code\": \"kimi\",\n\t} {\n\t\ttmpl, ok := GetProviderTemplate(alias)\n\t\tif !ok || tmpl.ProviderID != canonical {\n\t\t\tt.Errorf(\"provider 别名 %q 应解析为 %q,实际 %+v\", alias, canonical, tmpl)\n\t\t}\n\t}\n\tfor _, id := range []string{\"runapi\", \"unity2\"} {\n\t\tif _, ok := GetProviderTemplate(id); ok {\n\t\t\tt.Errorf(\"中转站 %s 不应进入自动识别模板\", id)\n\t\t}\n\t}\n\tif _, ok := GetProviderTemplate(\"nonexistent\"); ok {\n\t\tt.Error(\"不存在的 providerId 应返回 false\")\n\t}\n}\n\nfunc TestProviderTemplateCompshareModelCostMultipliers(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"compshare\")\n\tif !ok {\n\t\tt.Fatal(\"未找到优云智算模板\")\n\t}\n\n\twant := map[string]float64{\n\t\t\"glm-5.2\": 2,\n\t\t\"glm-5.1\": 6,\n\t\t\"minimax-m2.7\": 2,\n\t\t\"kimi-k2.6\": 5,\n\t\t\"deepseek-ai/deepseek-v3.2\": 1,\n\t\t\"deepseek-v4-flash\": 1,\n\t}\n\tfor modelID, expected := range want {\n\t\tgot, found := tmpl.ModelCostMultiplierForModel(strings.ToUpper(modelID))\n\t\tif !found || got != expected {\n\t\t\tt.Errorf(\"模型 %s 的优云倍率 = %v, %v;期望 %v\", modelID, got, found, expected)\n\t\t}\n\t}\n\tif _, found := tmpl.ModelCostMultiplierForModel(\"unknown-model\"); found {\n\t\tt.Error(\"未知优云模型不应返回成本倍率\")\n\t}\n\n\tglmPriority, glmFound := tmpl.ModelQualityPriorityForModel(\"GLM-5.1\")\n\tkimiPriority, kimiFound := tmpl.ModelQualityPriorityForModel(\"KIMI-K2.6\")\n\tif !glmFound || !kimiFound || glmPriority <= kimiPriority {\n\t\tt.Fatalf(\"优云同档质量优先级错误: glm=%d/%v kimi=%d/%v\", glmPriority, glmFound, kimiPriority, kimiFound)\n\t}\n\tif _, found := tmpl.ModelQualityPriorityForModel(\"unknown-model\"); found {\n\t\tt.Error(\"未知优云模型不应返回质量优先级\")\n\t}\n}\n\nfunc TestProviderTemplateModelCostMultiplierPrefersSpecificPattern(t *testing.T) {\n\ttmpl := ProviderTemplate{ModelCostMultipliers: map[string]float64{\n\t\t\"deepseek-*\": 3,\n\t\t\"deepseek-v4-*\": 1,\n\t}}\n\tgot, found := tmpl.ModelCostMultiplierForModel(\"DeepSeek-V4-Flash\")\n\tif !found || got != 1 {\n\t\tt.Fatalf(\"最长通配符倍率 = %v, %v;期望 1\", got, found)\n\t}\n}\n\nfunc TestEveryProviderCandidateCanBeInferred(t *testing.T) {\n\tfor _, tmpl := range ListProviderTemplates() {\n\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\tif len(route.Candidates) == 0 {\n\t\t\t\tt.Errorf(\"provider %s route %s 没有候选端点\", tmpl.ProviderID, route.ChannelKind)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, candidate := range route.Candidates {\n\t\t\t\tbaseURL := strings.TrimSuffix(candidate.BaseURL, \"#\")\n\t\t\t\tgot, ok := InferProviderIDFromBaseURL(baseURL)\n\t\t\t\tif !ok || got != tmpl.ProviderID {\n\t\t\t\t\tt.Errorf(\"候选端点 %q 反向识别为 %q, %v;期望 %q\", candidate.BaseURL, got, ok, tmpl.ProviderID)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestProviderTemplateOpenCodeRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"opencode-go\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 OpenCode 模板\")\n\t}\n\tif tmpl.ProviderID != \"opencode-zen\" || len(tmpl.AutoAddRoutes()) != 3 {\n\t\tt.Fatalf(\"OpenCode 模板不完整: %+v\", tmpl)\n\t}\n\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\tif route.ServiceType != \"openai\" || len(route.Candidates) != 2 {\n\t\t\tt.Fatalf(\"OpenCode %s route 应通过 OpenAI Chat 上游探测 Zen/Go 两个入口: %+v\", route.ChannelKind, route)\n\t\t}\n\t\tif route.Candidates[0].BaseURL != \"https://opencode.ai/zen/go/v1\" || route.Candidates[1].BaseURL != \"https://opencode.ai/zen/v1\" {\n\t\t\tt.Fatalf(\"OpenCode 候选顺序错误: %+v\", route.Candidates)\n\t\t}\n\t}\n}\n\nfunc TestProviderTemplateXFyunNativeResponsesRoute(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"xfyun\")\n\tif !ok {\n\t\tt.Fatal(\"未找到讯飞模板\")\n\t}\n\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\tif route.ChannelKind != \"responses\" {\n\t\t\tcontinue\n\t\t}\n\t\tif route.ServiceType != \"responses\" || len(route.Candidates) != 1 || !strings.HasSuffix(route.Candidates[0].BaseURL, \"/v1/responses\") {\n\t\t\tt.Fatalf(\"讯飞 Responses route 不符合预期: %+v\", route)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatal(\"讯飞模板缺少原生 Responses route\")\n}\n\nfunc TestProviderTemplatesDoNotExposeChannelPresetRefs(t *testing.T) {\n\tdata, err := json.Marshal(ListProviderTemplates())\n\tif err != nil {\n\t\tt.Fatalf(\"序列化 provider templates 失败: %v\", err)\n\t}\n\tfor _, field := range [][]byte{[]byte(\"presetRef\"), []byte(\"presetCollection\")} {\n\t\tif bytes.Contains(data, field) {\n\t\t\tt.Fatalf(\"provider template 不应暴露 channel preset 字段 %q: %s\", field, data)\n\t\t}\n\t}\n}\n"}, "59a3dce74a29648a68d5d56be731f06fd42e4215": {"t": 1785759316.5920382, "n": 13684, "h": "9b9bc1ff2db4a422", "c": "package config\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"slices\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestCandidatesForKeyPrefix(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"mimo\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 mimo 模板\")\n\t}\n\n\t// sk- 前缀 → payg 优先\n\tskCands := tmpl.CandidatesForKey(\"sk-abc\")\n\tif len(skCands) == 0 || skCands[0].PlanTag != \"payg\" {\n\t\tt.Errorf(\"sk- key 首选应为 payg,实际 %+v\", skCands)\n\t}\n\n\t// tp- 前缀 → token_plan 优先,且按 Priority(cn=0 先)\n\ttpCands := tmpl.CandidatesForKey(\"tp-xyz\")\n\tif len(tpCands) == 0 || tpCands[0].PlanTag != \"token_plan\" || tpCands[0].Region != \"cn\" {\n\t\tt.Errorf(\"tp- key 首选应为 token_plan/cn,实际 %+v\", tpCands)\n\t}\n\t// tp- 回退候选应包含 payg(全部候选都在)\n\tif len(tpCands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"tp- key 应返回全部候选(含回退)%d,实际 %d\", len(tmpl.Candidates), len(tpCands))\n\t}\n\n\t// 无匹配前缀 → 返回全部候选\n\tunknownCands := tmpl.CandidatesForKey(\"xx-none\")\n\tif len(unknownCands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"未匹配前缀应返回全部候选 %d,实际 %d\", len(tmpl.Candidates), len(unknownCands))\n\t}\n}\n\nfunc TestProviderTemplateMiMoRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"mimo\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 mimo 模板\")\n\t}\n\troutes := tmpl.AutoAddRoutes()\n\tif len(routes) != 4 {\n\t\tt.Fatalf(\"mimo 应创建 4 条 route,实际 %d: %+v\", len(routes), routes)\n\t}\n\n\twant := map[string]struct {\n\t\tserviceType string\n\t\tbaseSuffix string\n\t}{\n\t\t\"messages\": {serviceType: \"claude\", baseSuffix: \"/anthropic\"},\n\t\t\"chat\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t\t\"responses\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t\t\"gemini\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t}\n\tfor _, route := range routes {\n\t\texpect, ok := want[route.ChannelKind]\n\t\tif !ok {\n\t\t\tt.Fatalf(\"未知 route kind: %+v\", route)\n\t\t}\n\t\tif route.ServiceType != expect.serviceType {\n\t\t\tt.Fatalf(\"route %s serviceType=%s, want %s\", route.ChannelKind, route.ServiceType, expect.serviceType)\n\t\t}\n\t\tcandidates := tmpl.CandidatesForRouteKey(route, \"tp-test\")\n\t\tif len(candidates) == 0 {\n\t\t\tt.Fatalf(\"route %s 没有候选\", route.ChannelKind)\n\t\t}\n\t\tif !strings.HasSuffix(candidates[0].BaseURL, expect.baseSuffix) {\n\t\t\tt.Fatalf(\"route %s 首选 baseURL=%s, want suffix %s\", route.ChannelKind, candidates[0].BaseURL, expect.baseSuffix)\n\t\t}\n\t}\n\tif !tmpl.SupportsChannelKind(\"messages\") || !tmpl.SupportsChannelKind(\"chat\") || !tmpl.SupportsChannelKind(\"responses\") || !tmpl.SupportsChannelKind(\"gemini\") {\n\t\tt.Fatalf(\"mimo 应支持 messages/chat/responses/gemini routes: %+v\", routes)\n\t}\n}\n\nfunc TestCandidatesForKeyNoPrefixRules(t *testing.T) {\n\t// deepseek 无前缀规则,任意 key 返回全部候选\n\ttmpl, ok := GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 deepseek 模板\")\n\t}\n\tcands := tmpl.CandidatesForKey(\"anything\")\n\tif len(cands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"无前缀规则应返回全部候选 %d,实际 %d\", len(tmpl.Candidates), len(cands))\n\t}\n}\n\nfunc TestProviderTemplateDeepSeekRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 deepseek 模板\")\n\t}\n\troutes := tmpl.AutoAddRoutes()\n\tif len(routes) != 3 {\n\t\tt.Fatalf(\"deepseek 应创建 messages/chat/responses 三条 route,实际 %d: %+v\", len(routes), routes)\n\t}\n\twant := map[string]struct {\n\t\tserviceType string\n\t\tbaseURL string\n\t\tsupportedModels []string\n\t}{\n\t\t\"messages\": {serviceType: \"claude\", baseURL: \"https://api.deepseek.com/anthropic\"},\n\t\t\"chat\": {serviceType: \"openai\", baseURL: \"https://api.deepseek.com\"},\n\t\t// Responses 走官方原生入口,且仅 deepseek-v4-flash 可用\n\t\t\"responses\": {\n\t\t\tserviceType: \"responses\",\n\t\t\tbaseURL: \"https://api.deepseek.com/v1/responses\",\n\t\t\tsupportedModels: []string{\"deepseek-v4-flash\"},\n\t\t},\n\t}\n\tfor _, route := range routes {\n\t\texpect, found := want[route.ChannelKind]\n\t\tif !found || route.ServiceType != expect.serviceType || len(route.Candidates) != 1 || route.Candidates[0].BaseURL != expect.baseURL {\n\t\t\tt.Fatalf(\"DeepSeek route 不符合预期: %+v\", route)\n\t\t}\n\t\tif !slices.Equal(route.SupportedModels, expect.supportedModels) {\n\t\t\tt.Fatalf(\"DeepSeek route %s 模型白名单不符合预期: got %v, want %v\",\n\t\t\t\troute.ChannelKind, route.SupportedModels, expect.supportedModels)\n\t\t}\n\t}\n}\n\nfunc TestProviderTemplateGLMRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"glm\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 GLM 模板\")\n\t}\n\troutes := tmpl.AutoAddRoutes()\n\tif len(routes) != 3 {\n\t\tt.Fatalf(\"GLM 应创建 messages/chat/responses 三条 route,实际 %d: %+v\", len(routes), routes)\n\t}\n\twant := map[string]struct {\n\t\tserviceType string\n\t\tbaseURL string\n\t\tcandidates int\n\t}{\n\t\t\"messages\": {serviceType: \"claude\", baseURL: \"https://open.bigmodel.cn/api/anthropic\", candidates: 1},\n\t\t\"chat\": {serviceType: \"openai\", baseURL: \"https://open.bigmodel.cn/api/paas/v4#\", candidates: 2},\n\t\t\"responses\": {serviceType: \"openai\", baseURL: \"https://open.bigmodel.cn/api/paas/v4#\", candidates: 2},\n\t}\n\tfor _, route := range routes {\n\t\texpect, found := want[route.ChannelKind]\n\t\tif !found || route.ServiceType != expect.serviceType || len(route.Candidates) != expect.candidates || route.Candidates[0].BaseURL != expect.baseURL {\n\t\t\tt.Fatalf(\"GLM route 不符合预期: %+v\", route)\n\t\t}\n\t\tif route.Candidates[0].PlanTag != \"payg\" {\n\t\t\tt.Fatalf(\"GLM route 应标记为 payg: %+v\", route.Candidates[0])\n\t\t}\n\t\tif route.ChannelKind != \"messages\" && (route.Candidates[1].PlanTag != \"coding_plan\" || !strings.Contains(route.Candidates[1].BaseURL, \"/api/coding/\")) {\n\t\t\tt.Fatalf(\"GLM %s route 应包含 Coding Plan 候选: %+v\", route.ChannelKind, route.Candidates)\n\t\t}\n\t}\n}\n\nfunc TestInferProviderIDFromBaseURL(t *testing.T) {\n\ttests := []struct {\n\t\tbaseURL string\n\t\twant string\n\t\tok bool\n\t}{\n\t\t{baseURL: \"https://api.deepseek.com\", want: \"deepseek\", ok: true},\n\t\t{baseURL: \"https://api.deepseek.com/anthropic/v1\", want: \"deepseek\", ok: true},\n\t\t{baseURL: \"https://ark.cn-beijing.volces.com/api/plan/v3\", want: \"volcengine\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/anthropic\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/anthropic/v1/messages\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/paas/v4/\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/paas/v4/chat/completions\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://opencode.ai/zen/go/v1/chat/completions\", want: \"opencode-zen\", ok: true},\n\t\t{baseURL: \"https://opencode.ai/zen/v1\", want: \"opencode-zen\", ok: true},\n\t\t{baseURL: \"https://opencode.ai.evil.example/zen/v1\", ok: false},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/other\", ok: false},\n\t\t{baseURL: \"https://open.bigmodel.cn.evil.example/api/paas/v4\", ok: false},\n\t\t{baseURL: \"https://relay.example/v1\", ok: false},\n\t\t{baseURL: \"https://api.deepseek.com.evil.example\", ok: false},\n\t}\n\tfor _, tt := range tests {\n\t\tgot, ok := InferProviderIDFromBaseURL(tt.baseURL)\n\t\tif got != tt.want || ok != tt.ok {\n\t\t\tt.Fatalf(\"InferProviderIDFromBaseURL(%q) = %q, %v; want %q, %v\", tt.baseURL, got, ok, tt.want, tt.ok)\n\t\t}\n\t}\n}\n\nfunc TestInferProviderIDFromAPIKey(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tapiKey string\n\t\twant string\n\t\tok bool\n\t}{\n\t\t{name: \"zhipu id.secret\", apiKey: \"0123456789abcdef0123456789abcdef.ABCDEFGHIJKLMNO1\", want: \"glm\", ok: true},\n\t\t{name: \"trim spaces\", apiKey: \" 269abc123456789012345678.r8abcdef1234 \", want: \"glm\", ok: true},\n\t\t{name: \"shared sk prefix\", apiKey: \"sk-abcdefghijklmnopqrstuvwxyz123456\", ok: false},\n\t\t{name: \"short dotted token\", apiKey: \"abc.def\", ok: false},\n\t\t{name: \"jwt\", apiKey: \"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature\", ok: false},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, ok := InferProviderIDFromAPIKey(tt.apiKey)\n\t\t\tif got != tt.want || ok != tt.ok {\n\t\t\t\tt.Fatalf(\"InferProviderIDFromAPIKey() = %q, %v; want %q, %v\", got, ok, tt.want, tt.ok)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestProviderTemplateVolcenginePlanRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"volcengine\")\n\tif !ok {\n\t\tt.Fatal(\"未找到火山方舟模板\")\n\t}\n\tif tmpl.DisplayName != \"火山方舟 Agent/Coding Plan\" || len(tmpl.AutoAddRoutes()) != 4 {\n\t\tt.Fatalf(\"火山模板不完整: %+v\", tmpl)\n\t}\n\twantBases := map[string][2]string{\n\t\t\"messages\": {\"https://ark.cn-beijing.volces.com/api/plan\", \"https://ark.cn-beijing.volces.com/api/coding\"},\n\t\t\"chat\": {\"https://ark.cn-beijing.volces.com/api/plan/v3\", \"https://ark.cn-beijing.volces.com/api/coding/v3\"},\n\t\t\"responses\": {\"https://ark.cn-beijing.volces.com/api/plan/v3\", \"https://ark.cn-beijing.volces.com/api/coding/v3\"},\n\t\t\"gemini\": {\"https://ark.cn-beijing.volces.com/api/plan/v3\", \"https://ark.cn-beijing.volces.com/api/coding/v3\"},\n\t}\n\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\tcandidates := tmpl.CandidatesForRouteKey(route, \"ark-test\")\n\t\tif len(candidates) != 2 {\n\t\t\tt.Fatalf(\"route %s 应包含 Agent/Coding 两个候选: %+v\", route.ChannelKind, candidates)\n\t\t}\n\t\twant, exists := wantBases[route.ChannelKind]\n\t\tif !exists || candidates[0].BaseURL != want[0] || candidates[1].BaseURL != want[1] {\n\t\t\tt.Fatalf(\"route %s Base URL 错误: got=%q/%q want=%q/%q\", route.ChannelKind,\n\t\t\t\tcandidates[0].BaseURL, candidates[1].BaseURL, want[0], want[1])\n\t\t}\n\t}\n}\n\nfunc TestListAndGetProviderTemplate(t *testing.T) {\n\tall := ListProviderTemplates()\n\tif len(all) < 16 {\n\t\tt.Errorf(\"内置 provider 模板应至少为 16 个,实际 %d\", len(all))\n\t}\n\tfor _, id := range []string{\n\t\t\"mimo\", \"deepseek\", \"kimi\", \"glm\", \"volcengine\", \"compshare\", \"sensenova\", \"minimax\",\n\t\t\"dashscope\", \"opencode-zen\", \"tencent-lkeap\", \"qianfan\", \"xfyun\", \"openrouter\", \"modelscope\", \"originrouter\",\n\t} {\n\t\tif _, ok := GetProviderTemplate(id); !ok {\n\t\t\tt.Errorf(\"缺少 provider 模板: %s\", id)\n\t\t}\n\t}\n\tfor alias, canonical := range map[string]string{\n\t\t\" opencode-go \": \"opencode-zen\",\n\t\t\"VOLC-ARK\": \"volcengine\",\n\t\t\"kimi-code\": \"kimi\",\n\t} {\n\t\ttmpl, ok := GetProviderTemplate(alias)\n\t\tif !ok || tmpl.ProviderID != canonical {\n\t\t\tt.Errorf(\"provider 别名 %q 应解析为 %q,实际 %+v\", alias, canonical, tmpl)\n\t\t}\n\t}\n\tfor _, id := range []string{\"runapi\", \"unity2\"} {\n\t\tif _, ok := GetProviderTemplate(id); ok {\n\t\t\tt.Errorf(\"中转站 %s 不应进入自动识别模板\", id)\n\t\t}\n\t}\n\tif _, ok := GetProviderTemplate(\"nonexistent\"); ok {\n\t\tt.Error(\"不存在的 providerId 应返回 false\")\n\t}\n}\n\nfunc TestProviderTemplateCompshareModelCostMultipliers(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"compshare\")\n\tif !ok {\n\t\tt.Fatal(\"未找到优云智算模板\")\n\t}\n\n\twant := map[string]float64{\n\t\t\"glm-5.2\": 2,\n\t\t\"glm-5.1\": 6,\n\t\t\"minimax-m2.7\": 2,\n\t\t\"kimi-k2.6\": 5,\n\t\t\"deepseek-ai/deepseek-v3.2\": 1,\n\t\t\"deepseek-v4-flash\": 1,\n\t}\n\tfor modelID, expected := range want {\n\t\tgot, found := tmpl.ModelCostMultiplierForModel(strings.ToUpper(modelID))\n\t\tif !found || got != expected {\n\t\t\tt.Errorf(\"模型 %s 的优云倍率 = %v, %v;期望 %v\", modelID, got, found, expected)\n\t\t}\n\t}\n\tif _, found := tmpl.ModelCostMultiplierForModel(\"unknown-model\"); found {\n\t\tt.Error(\"未知优云模型不应返回成本倍率\")\n\t}\n\n\tglmPriority, glmFound := tmpl.ModelQualityPriorityForModel(\"GLM-5.1\")\n\tkimiPriority, kimiFound := tmpl.ModelQualityPriorityForModel(\"KIMI-K2.6\")\n\tif !glmFound || !kimiFound || glmPriority <= kimiPriority {\n\t\tt.Fatalf(\"优云同档质量优先级错误: glm=%d/%v kimi=%d/%v\", glmPriority, glmFound, kimiPriority, kimiFound)\n\t}\n\tif _, found := tmpl.ModelQualityPriorityForModel(\"unknown-model\"); found {\n\t\tt.Error(\"未知优云模型不应返回质量优先级\")\n\t}\n}\n\nfunc TestProviderTemplateModelCostMultiplierPrefersSpecificPattern(t *testing.T) {\n\ttmpl := ProviderTemplate{ModelCostMultipliers: map[string]float64{\n\t\t\"deepseek-*\": 3,\n\t\t\"deepseek-v4-*\": 1,\n\t}}\n\tgot, found := tmpl.ModelCostMultiplierForModel(\"DeepSeek-V4-Flash\")\n\tif !found || got != 1 {\n\t\tt.Fatalf(\"最长通配符倍率 = %v, %v;期望 1\", got, found)\n\t}\n}\n\nfunc TestEveryProviderCandidateCanBeInferred(t *testing.T) {\n\tfor _, tmpl := range ListProviderTemplates() {\n\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\tif len(route.Candidates) == 0 {\n\t\t\t\tt.Errorf(\"provider %s route %s 没有候选端点\", tmpl.ProviderID, route.ChannelKind)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, candidate := range route.Candidates {\n\t\t\t\tbaseURL := strings.TrimSuffix(candidate.BaseURL, \"#\")\n\t\t\t\tgot, ok := InferProviderIDFromBaseURL(baseURL)\n\t\t\t\tif !ok || got != tmpl.ProviderID {\n\t\t\t\t\tt.Errorf(\"候选端点 %q 反向识别为 %q, %v;期望 %q\", candidate.BaseURL, got, ok, tmpl.ProviderID)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestProviderTemplateOpenCodeRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"opencode-go\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 OpenCode 模板\")\n\t}\n\tif tmpl.ProviderID != \"opencode-zen\" || len(tmpl.AutoAddRoutes()) != 3 {\n\t\tt.Fatalf(\"OpenCode 模板不完整: %+v\", tmpl)\n\t}\n\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\tif route.ServiceType != \"openai\" || len(route.Candidates) != 2 {\n\t\t\tt.Fatalf(\"OpenCode %s route 应通过 OpenAI Chat 上游探测 Zen/Go 两个入口: %+v\", route.ChannelKind, route)\n\t\t}\n\t\tif route.Candidates[0].BaseURL != \"https://opencode.ai/zen/go/v1\" || route.Candidates[1].BaseURL != \"https://opencode.ai/zen/v1\" {\n\t\t\tt.Fatalf(\"OpenCode 候选顺序错误: %+v\", route.Candidates)\n\t\t}\n\t}\n}\n\nfunc TestProviderTemplateXFyunNativeResponsesRoute(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"xfyun\")\n\tif !ok {\n\t\tt.Fatal(\"未找到讯飞模板\")\n\t}\n\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\tif route.ChannelKind != \"responses\" {\n\t\t\tcontinue\n\t\t}\n\t\tif route.ServiceType != \"responses\" || len(route.Candidates) != 1 || !strings.HasSuffix(route.Candidates[0].BaseURL, \"/v1/responses\") {\n\t\t\tt.Fatalf(\"讯飞 Responses route 不符合预期: %+v\", route)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatal(\"讯飞模板缺少原生 Responses route\")\n}\n\nfunc TestProviderTemplatesDoNotExposeChannelPresetRefs(t *testing.T) {\n\tdata, err := json.Marshal(ListProviderTemplates())\n\tif err != nil {\n\t\tt.Fatalf(\"序列化 provider templates 失败: %v\", err)\n\t}\n\tfor _, field := range [][]byte{[]byte(\"presetRef\"), []byte(\"presetCollection\")} {\n\t\tif bytes.Contains(data, field) {\n\t\t\tt.Fatalf(\"provider template 不应暴露 channel preset 字段 %q: %s\", field, data)\n\t\t}\n\t}\n}\n"}, "59c3927ffbcde49bc2f2a8495991028e87c7deeb": {"t": 1785759317.8170228, "n": 13301, "h": "9ff8f87db625a1b8", "c": "package config\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestCandidatesForKeyPrefix(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"mimo\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 mimo 模板\")\n\t}\n\n\t// sk- 前缀 → payg 优先\n\tskCands := tmpl.CandidatesForKey(\"sk-abc\")\n\tif len(skCands) == 0 || skCands[0].PlanTag != \"payg\" {\n\t\tt.Errorf(\"sk- key 首选应为 payg,实际 %+v\", skCands)\n\t}\n\n\t// tp- 前缀 → token_plan 优先,且按 Priority(cn=0 先)\n\ttpCands := tmpl.CandidatesForKey(\"tp-xyz\")\n\tif len(tpCands) == 0 || tpCands[0].PlanTag != \"token_plan\" || tpCands[0].Region != \"cn\" {\n\t\tt.Errorf(\"tp- key 首选应为 token_plan/cn,实际 %+v\", tpCands)\n\t}\n\t// tp- 回退候选应包含 payg(全部候选都在)\n\tif len(tpCands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"tp- key 应返回全部候选(含回退)%d,实际 %d\", len(tmpl.Candidates), len(tpCands))\n\t}\n\n\t// 无匹配前缀 → 返回全部候选\n\tunknownCands := tmpl.CandidatesForKey(\"xx-none\")\n\tif len(unknownCands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"未匹配前缀应返回全部候选 %d,实际 %d\", len(tmpl.Candidates), len(unknownCands))\n\t}\n}\n\nfunc TestProviderTemplateMiMoRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"mimo\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 mimo 模板\")\n\t}\n\troutes := tmpl.AutoAddRoutes()\n\tif len(routes) != 4 {\n\t\tt.Fatalf(\"mimo 应创建 4 条 route,实际 %d: %+v\", len(routes), routes)\n\t}\n\n\twant := map[string]struct {\n\t\tserviceType string\n\t\tbaseSuffix string\n\t}{\n\t\t\"messages\": {serviceType: \"claude\", baseSuffix: \"/anthropic\"},\n\t\t\"chat\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t\t\"responses\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t\t\"gemini\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t}\n\tfor _, route := range routes {\n\t\texpect, ok := want[route.ChannelKind]\n\t\tif !ok {\n\t\t\tt.Fatalf(\"未知 route kind: %+v\", route)\n\t\t}\n\t\tif route.ServiceType != expect.serviceType {\n\t\t\tt.Fatalf(\"route %s serviceType=%s, want %s\", route.ChannelKind, route.ServiceType, expect.serviceType)\n\t\t}\n\t\tcandidates := tmpl.CandidatesForRouteKey(route, \"tp-test\")\n\t\tif len(candidates) == 0 {\n\t\t\tt.Fatalf(\"route %s 没有候选\", route.ChannelKind)\n\t\t}\n\t\tif !strings.HasSuffix(candidates[0].BaseURL, expect.baseSuffix) {\n\t\t\tt.Fatalf(\"route %s 首选 baseURL=%s, want suffix %s\", route.ChannelKind, candidates[0].BaseURL, expect.baseSuffix)\n\t\t}\n\t}\n\tif !tmpl.SupportsChannelKind(\"messages\") || !tmpl.SupportsChannelKind(\"chat\") || !tmpl.SupportsChannelKind(\"responses\") || !tmpl.SupportsChannelKind(\"gemini\") {\n\t\tt.Fatalf(\"mimo 应支持 messages/chat/responses/gemini routes: %+v\", routes)\n\t}\n}\n\nfunc TestCandidatesForKeyNoPrefixRules(t *testing.T) {\n\t// deepseek 无前缀规则,任意 key 返回全部候选\n\ttmpl, ok := GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 deepseek 模板\")\n\t}\n\tcands := tmpl.CandidatesForKey(\"anything\")\n\tif len(cands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"无前缀规则应返回全部候选 %d,实际 %d\", len(tmpl.Candidates), len(cands))\n\t}\n}\n\nfunc TestProviderTemplateDeepSeekRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 deepseek 模板\")\n\t}\n\troutes := tmpl.AutoAddRoutes()\n\tif len(routes) != 3 {\n\t\tt.Fatalf(\"deepseek 应创建 messages/chat/responses 三条 route,实际 %d: %+v\", len(routes), routes)\n\t}\n\twant := map[string]struct {\n\t\tserviceType string\n\t\tbaseURL string\n\t}{\n\t\t\"messages\": {serviceType: \"claude\", baseURL: \"https://api.deepseek.com/anthropic\"},\n\t\t\"chat\": {serviceType: \"openai\", baseURL: \"https://api.deepseek.com\"},\n\t\t\"responses\": {serviceType: \"openai\", baseURL: \"https://api.deepseek.com\"},\n\t}\n\tfor _, route := range routes {\n\t\texpect, found := want[route.ChannelKind]\n\t\tif !found || route.ServiceType != expect.serviceType || len(route.Candidates) != 1 || route.Candidates[0].BaseURL != expect.baseURL {\n\t\t\tt.Fatalf(\"DeepSeek route 不符合预期: %+v\", route)\n\t\t}\n\t}\n}\n\nfunc TestProviderTemplateGLMRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"glm\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 GLM 模板\")\n\t}\n\troutes := tmpl.AutoAddRoutes()\n\tif len(routes) != 3 {\n\t\tt.Fatalf(\"GLM 应创建 messages/chat/responses 三条 route,实际 %d: %+v\", len(routes), routes)\n\t}\n\twant := map[string]struct {\n\t\tserviceType string\n\t\tbaseURL string\n\t\tcandidates int\n\t}{\n\t\t\"messages\": {serviceType: \"claude\", baseURL: \"https://open.bigmodel.cn/api/anthropic\", candidates: 1},\n\t\t\"chat\": {serviceType: \"openai\", baseURL: \"https://open.bigmodel.cn/api/paas/v4#\", candidates: 2},\n\t\t\"responses\": {serviceType: \"openai\", baseURL: \"https://open.bigmodel.cn/api/paas/v4#\", candidates: 2},\n\t}\n\tfor _, route := range routes {\n\t\texpect, found := want[route.ChannelKind]\n\t\tif !found || route.ServiceType != expect.serviceType || len(route.Candidates) != expect.candidates || route.Candidates[0].BaseURL != expect.baseURL {\n\t\t\tt.Fatalf(\"GLM route 不符合预期: %+v\", route)\n\t\t}\n\t\tif route.Candidates[0].PlanTag != \"payg\" {\n\t\t\tt.Fatalf(\"GLM route 应标记为 payg: %+v\", route.Candidates[0])\n\t\t}\n\t\tif route.ChannelKind != \"messages\" && (route.Candidates[1].PlanTag != \"coding_plan\" || !strings.Contains(route.Candidates[1].BaseURL, \"/api/coding/\")) {\n\t\t\tt.Fatalf(\"GLM %s route 应包含 Coding Plan 候选: %+v\", route.ChannelKind, route.Candidates)\n\t\t}\n\t}\n}\n\nfunc TestInferProviderIDFromBaseURL(t *testing.T) {\n\ttests := []struct {\n\t\tbaseURL string\n\t\twant string\n\t\tok bool\n\t}{\n\t\t{baseURL: \"https://api.deepseek.com\", want: \"deepseek\", ok: true},\n\t\t{baseURL: \"https://api.deepseek.com/anthropic/v1\", want: \"deepseek\", ok: true},\n\t\t{baseURL: \"https://ark.cn-beijing.volces.com/api/plan/v3\", want: \"volcengine\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/anthropic\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/anthropic/v1/messages\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/paas/v4/\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/paas/v4/chat/completions\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://opencode.ai/zen/go/v1/chat/completions\", want: \"opencode-zen\", ok: true},\n\t\t{baseURL: \"https://opencode.ai/zen/v1\", want: \"opencode-zen\", ok: true},\n\t\t{baseURL: \"https://opencode.ai.evil.example/zen/v1\", ok: false},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/other\", ok: false},\n\t\t{baseURL: \"https://open.bigmodel.cn.evil.example/api/paas/v4\", ok: false},\n\t\t{baseURL: \"https://relay.example/v1\", ok: false},\n\t\t{baseURL: \"https://api.deepseek.com.evil.example\", ok: false},\n\t}\n\tfor _, tt := range tests {\n\t\tgot, ok := InferProviderIDFromBaseURL(tt.baseURL)\n\t\tif got != tt.want || ok != tt.ok {\n\t\t\tt.Fatalf(\"InferProviderIDFromBaseURL(%q) = %q, %v; want %q, %v\", tt.baseURL, got, ok, tt.want, tt.ok)\n\t\t}\n\t}\n}\n\nfunc TestInferProviderIDFromAPIKey(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tapiKey string\n\t\twant string\n\t\tok bool\n\t}{\n\t\t{name: \"zhipu id.secret\", apiKey: \"0123456789abcdef0123456789abcdef.ABCDEFGHIJKLMNO1\", want: \"glm\", ok: true},\n\t\t{name: \"trim spaces\", apiKey: \" 269abc123456789012345678.r8abcdef1234 \", want: \"glm\", ok: true},\n\t\t{name: \"shared sk prefix\", apiKey: \"sk-abcdefghijklmnopqrstuvwxyz123456\", ok: false},\n\t\t{name: \"short dotted token\", apiKey: \"abc.def\", ok: false},\n\t\t{name: \"jwt\", apiKey: \"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature\", ok: false},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, ok := InferProviderIDFromAPIKey(tt.apiKey)\n\t\t\tif got != tt.want || ok != tt.ok {\n\t\t\t\tt.Fatalf(\"InferProviderIDFromAPIKey() = %q, %v; want %q, %v\", got, ok, tt.want, tt.ok)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestProviderTemplateVolcenginePlanRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"volcengine\")\n\tif !ok {\n\t\tt.Fatal(\"未找到火山方舟模板\")\n\t}\n\tif tmpl.DisplayName != \"火山方舟 Agent/Coding Plan\" || len(tmpl.AutoAddRoutes()) != 4 {\n\t\tt.Fatalf(\"火山模板不完整: %+v\", tmpl)\n\t}\n\twantBases := map[string][2]string{\n\t\t\"messages\": {\"https://ark.cn-beijing.volces.com/api/plan\", \"https://ark.cn-beijing.volces.com/api/coding\"},\n\t\t\"chat\": {\"https://ark.cn-beijing.volces.com/api/plan/v3\", \"https://ark.cn-beijing.volces.com/api/coding/v3\"},\n\t\t\"responses\": {\"https://ark.cn-beijing.volces.com/api/plan/v3\", \"https://ark.cn-beijing.volces.com/api/coding/v3\"},\n\t\t\"gemini\": {\"https://ark.cn-beijing.volces.com/api/plan/v3\", \"https://ark.cn-beijing.volces.com/api/coding/v3\"},\n\t}\n\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\tcandidates := tmpl.CandidatesForRouteKey(route, \"ark-test\")\n\t\tif len(candidates) != 2 {\n\t\t\tt.Fatalf(\"route %s 应包含 Agent/Coding 两个候选: %+v\", route.ChannelKind, candidates)\n\t\t}\n\t\twant, exists := wantBases[route.ChannelKind]\n\t\tif !exists || candidates[0].BaseURL != want[0] || candidates[1].BaseURL != want[1] {\n\t\t\tt.Fatalf(\"route %s Base URL 错误: got=%q/%q want=%q/%q\", route.ChannelKind,\n\t\t\t\tcandidates[0].BaseURL, candidates[1].BaseURL, want[0], want[1])\n\t\t}\n\t}\n}\n\nfunc TestListAndGetProviderTemplate(t *testing.T) {\n\tall := ListProviderTemplates()\n\tif len(all) < 16 {\n\t\tt.Errorf(\"内置 provider 模板应至少为 16 个,实际 %d\", len(all))\n\t}\n\tfor _, id := range []string{\n\t\t\"mimo\", \"deepseek\", \"kimi\", \"glm\", \"volcengine\", \"compshare\", \"sensenova\", \"minimax\",\n\t\t\"dashscope\", \"opencode-zen\", \"tencent-lkeap\", \"qianfan\", \"xfyun\", \"openrouter\", \"modelscope\", \"originrouter\",\n\t} {\n\t\tif _, ok := GetProviderTemplate(id); !ok {\n\t\t\tt.Errorf(\"缺少 provider 模板: %s\", id)\n\t\t}\n\t}\n\tfor alias, canonical := range map[string]string{\n\t\t\" opencode-go \": \"opencode-zen\",\n\t\t\"VOLC-ARK\": \"volcengine\",\n\t\t\"kimi-code\": \"kimi\",\n\t} {\n\t\ttmpl, ok := GetProviderTemplate(alias)\n\t\tif !ok || tmpl.ProviderID != canonical {\n\t\t\tt.Errorf(\"provider 别名 %q 应解析为 %q,实际 %+v\", alias, canonical, tmpl)\n\t\t}\n\t}\n\tfor _, id := range []string{\"runapi\", \"unity2\"} {\n\t\tif _, ok := GetProviderTemplate(id); ok {\n\t\t\tt.Errorf(\"中转站 %s 不应进入自动识别模板\", id)\n\t\t}\n\t}\n\tif _, ok := GetProviderTemplate(\"nonexistent\"); ok {\n\t\tt.Error(\"不存在的 providerId 应返回 false\")\n\t}\n}\n\nfunc TestProviderTemplateCompshareModelCostMultipliers(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"compshare\")\n\tif !ok {\n\t\tt.Fatal(\"未找到优云智算模板\")\n\t}\n\n\twant := map[string]float64{\n\t\t\"glm-5.2\": 2,\n\t\t\"glm-5.1\": 6,\n\t\t\"minimax-m2.7\": 2,\n\t\t\"kimi-k2.6\": 5,\n\t\t\"deepseek-ai/deepseek-v3.2\": 1,\n\t\t\"deepseek-v4-flash\": 1,\n\t}\n\tfor modelID, expected := range want {\n\t\tgot, found := tmpl.ModelCostMultiplierForModel(strings.ToUpper(modelID))\n\t\tif !found || got != expected {\n\t\t\tt.Errorf(\"模型 %s 的优云倍率 = %v, %v;期望 %v\", modelID, got, found, expected)\n\t\t}\n\t}\n\tif _, found := tmpl.ModelCostMultiplierForModel(\"unknown-model\"); found {\n\t\tt.Error(\"未知优云模型不应返回成本倍率\")\n\t}\n\n\tglmPriority, glmFound := tmpl.ModelQualityPriorityForModel(\"GLM-5.1\")\n\tkimiPriority, kimiFound := tmpl.ModelQualityPriorityForModel(\"KIMI-K2.6\")\n\tif !glmFound || !kimiFound || glmPriority <= kimiPriority {\n\t\tt.Fatalf(\"优云同档质量优先级错误: glm=%d/%v kimi=%d/%v\", glmPriority, glmFound, kimiPriority, kimiFound)\n\t}\n\tif _, found := tmpl.ModelQualityPriorityForModel(\"unknown-model\"); found {\n\t\tt.Error(\"未知优云模型不应返回质量优先级\")\n\t}\n}\n\nfunc TestProviderTemplateModelCostMultiplierPrefersSpecificPattern(t *testing.T) {\n\ttmpl := ProviderTemplate{ModelCostMultipliers: map[string]float64{\n\t\t\"deepseek-*\": 3,\n\t\t\"deepseek-v4-*\": 1,\n\t}}\n\tgot, found := tmpl.ModelCostMultiplierForModel(\"DeepSeek-V4-Flash\")\n\tif !found || got != 1 {\n\t\tt.Fatalf(\"最长通配符倍率 = %v, %v;期望 1\", got, found)\n\t}\n}\n\nfunc TestEveryProviderCandidateCanBeInferred(t *testing.T) {\n\tfor _, tmpl := range ListProviderTemplates() {\n\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\tif len(route.Candidates) == 0 {\n\t\t\t\tt.Errorf(\"provider %s route %s 没有候选端点\", tmpl.ProviderID, route.ChannelKind)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, candidate := range route.Candidates {\n\t\t\t\tbaseURL := strings.TrimSuffix(candidate.BaseURL, \"#\")\n\t\t\t\tgot, ok := InferProviderIDFromBaseURL(baseURL)\n\t\t\t\tif !ok || got != tmpl.ProviderID {\n\t\t\t\t\tt.Errorf(\"候选端点 %q 反向识别为 %q, %v;期望 %q\", candidate.BaseURL, got, ok, tmpl.ProviderID)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestProviderTemplateOpenCodeRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"opencode-go\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 OpenCode 模板\")\n\t}\n\tif tmpl.ProviderID != \"opencode-zen\" || len(tmpl.AutoAddRoutes()) != 3 {\n\t\tt.Fatalf(\"OpenCode 模板不完整: %+v\", tmpl)\n\t}\n\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\tif route.ServiceType != \"openai\" || len(route.Candidates) != 2 {\n\t\t\tt.Fatalf(\"OpenCode %s route 应通过 OpenAI Chat 上游探测 Zen/Go 两个入口: %+v\", route.ChannelKind, route)\n\t\t}\n\t\tif route.Candidates[0].BaseURL != \"https://opencode.ai/zen/go/v1\" || route.Candidates[1].BaseURL != \"https://opencode.ai/zen/v1\" {\n\t\t\tt.Fatalf(\"OpenCode 候选顺序错误: %+v\", route.Candidates)\n\t\t}\n\t}\n}\n\nfunc TestProviderTemplateXFyunNativeResponsesRoute(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"xfyun\")\n\tif !ok {\n\t\tt.Fatal(\"未找到讯飞模板\")\n\t}\n\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\tif route.ChannelKind != \"responses\" {\n\t\t\tcontinue\n\t\t}\n\t\tif route.ServiceType != \"responses\" || len(route.Candidates) != 1 || !strings.HasSuffix(route.Candidates[0].BaseURL, \"/v1/responses\") {\n\t\t\tt.Fatalf(\"讯飞 Responses route 不符合预期: %+v\", route)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatal(\"讯飞模板缺少原生 Responses route\")\n}\n\nfunc TestProviderTemplatesDoNotExposeChannelPresetRefs(t *testing.T) {\n\tdata, err := json.Marshal(ListProviderTemplates())\n\tif err != nil {\n\t\tt.Fatalf(\"序列化 provider templates 失败: %v\", err)\n\t}\n\tfor _, field := range [][]byte{[]byte(\"presetRef\"), []byte(\"presetCollection\")} {\n\t\tif bytes.Contains(data, field) {\n\t\t\tt.Fatalf(\"provider template 不应暴露 channel preset 字段 %q: %s\", field, data)\n\t\t}\n\t}\n}\n"}, "2a8bcfc193af0d67b777323a15297d090042a68f": {"t": 1785759322.4118383, "n": 12886, "h": "c8e4352faa68dae0", "c": "package config\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestCandidatesForKeyPrefix(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"mimo\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 mimo 模板\")\n\t}\n\n\t// sk- 前缀 → payg 优先\n\tskCands := tmpl.CandidatesForKey(\"sk-abc\")\n\tif len(skCands) == 0 || skCands[0].PlanTag != \"payg\" {\n\t\tt.Errorf(\"sk- key 首选应为 payg,实际 %+v\", skCands)\n\t}\n\n\t// tp- 前缀 → token_plan 优先,且按 Priority(cn=0 先)\n\ttpCands := tmpl.CandidatesForKey(\"tp-xyz\")\n\tif len(tpCands) == 0 || tpCands[0].PlanTag != \"token_plan\" || tpCands[0].Region != \"cn\" {\n\t\tt.Errorf(\"tp- key 首选应为 token_plan/cn,实际 %+v\", tpCands)\n\t}\n\t// tp- 回退候选应包含 payg(全部候选都在)\n\tif len(tpCands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"tp- key 应返回全部候选(含回退)%d,实际 %d\", len(tmpl.Candidates), len(tpCands))\n\t}\n\n\t// 无匹配前缀 → 返回全部候选\n\tunknownCands := tmpl.CandidatesForKey(\"xx-none\")\n\tif len(unknownCands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"未匹配前缀应返回全部候选 %d,实际 %d\", len(tmpl.Candidates), len(unknownCands))\n\t}\n}\n\nfunc TestProviderTemplateMiMoRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"mimo\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 mimo 模板\")\n\t}\n\troutes := tmpl.AutoAddRoutes()\n\tif len(routes) != 4 {\n\t\tt.Fatalf(\"mimo 应创建 4 条 route,实际 %d: %+v\", len(routes), routes)\n\t}\n\n\twant := map[string]struct {\n\t\tserviceType string\n\t\tbaseSuffix string\n\t}{\n\t\t\"messages\": {serviceType: \"claude\", baseSuffix: \"/anthropic\"},\n\t\t\"chat\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t\t\"responses\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t\t\"gemini\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t}\n\tfor _, route := range routes {\n\t\texpect, ok := want[route.ChannelKind]\n\t\tif !ok {\n\t\t\tt.Fatalf(\"未知 route kind: %+v\", route)\n\t\t}\n\t\tif route.ServiceType != expect.serviceType {\n\t\t\tt.Fatalf(\"route %s serviceType=%s, want %s\", route.ChannelKind, route.ServiceType, expect.serviceType)\n\t\t}\n\t\tcandidates := tmpl.CandidatesForRouteKey(route, \"tp-test\")\n\t\tif len(candidates) == 0 {\n\t\t\tt.Fatalf(\"route %s 没有候选\", route.ChannelKind)\n\t\t}\n\t\tif !strings.HasSuffix(candidates[0].BaseURL, expect.baseSuffix) {\n\t\t\tt.Fatalf(\"route %s 首选 baseURL=%s, want suffix %s\", route.ChannelKind, candidates[0].BaseURL, expect.baseSuffix)\n\t\t}\n\t}\n\tif !tmpl.SupportsChannelKind(\"messages\") || !tmpl.SupportsChannelKind(\"chat\") || !tmpl.SupportsChannelKind(\"responses\") || !tmpl.SupportsChannelKind(\"gemini\") {\n\t\tt.Fatalf(\"mimo 应支持 messages/chat/responses/gemini routes: %+v\", routes)\n\t}\n}\n\nfunc TestCandidatesForKeyNoPrefixRules(t *testing.T) {\n\t// deepseek 无前缀规则,任意 key 返回全部候选\n\ttmpl, ok := GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 deepseek 模板\")\n\t}\n\tcands := tmpl.CandidatesForKey(\"anything\")\n\tif len(cands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"无前缀规则应返回全部候选 %d,实际 %d\", len(tmpl.Candidates), len(cands))\n\t}\n}\n\nfunc TestProviderTemplateDeepSeekRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 deepseek 模板\")\n\t}\n\troutes := tmpl.AutoAddRoutes()\n\tif len(routes) != 3 {\n\t\tt.Fatalf(\"deepseek 应创建 messages/chat/responses 三条 route,实际 %d: %+v\", len(routes), routes)\n\t}\n\twant := map[string]struct {\n\t\tserviceType string\n\t\tbaseURL string\n\t}{\n\t\t\"messages\": {serviceType: \"claude\", baseURL: \"https://api.deepseek.com/anthropic\"},\n\t\t\"chat\": {serviceType: \"openai\", baseURL: \"https://api.deepseek.com\"},\n\t\t\"responses\": {serviceType: \"openai\", baseURL: \"https://api.deepseek.com\"},\n\t}\n\tfor _, route := range routes {\n\t\texpect, found := want[route.ChannelKind]\n\t\tif !found || route.ServiceType != expect.serviceType || len(route.Candidates) != 1 || route.Candidates[0].BaseURL != expect.baseURL {\n\t\t\tt.Fatalf(\"DeepSeek route 不符合预期: %+v\", route)\n\t\t}\n\t}\n}\n\nfunc TestProviderTemplateGLMRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"glm\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 GLM 模板\")\n\t}\n\troutes := tmpl.AutoAddRoutes()\n\tif len(routes) != 3 {\n\t\tt.Fatalf(\"GLM 应创建 messages/chat/responses 三条 route,实际 %d: %+v\", len(routes), routes)\n\t}\n\twant := map[string]struct {\n\t\tserviceType string\n\t\tbaseURL string\n\t\tcandidates int\n\t}{\n\t\t\"messages\": {serviceType: \"claude\", baseURL: \"https://open.bigmodel.cn/api/anthropic\", candidates: 1},\n\t\t\"chat\": {serviceType: \"openai\", baseURL: \"https://open.bigmodel.cn/api/paas/v4#\", candidates: 2},\n\t\t\"responses\": {serviceType: \"openai\", baseURL: \"https://open.bigmodel.cn/api/paas/v4#\", candidates: 2},\n\t}\n\tfor _, route := range routes {\n\t\texpect, found := want[route.ChannelKind]\n\t\tif !found || route.ServiceType != expect.serviceType || len(route.Candidates) != expect.candidates || route.Candidates[0].BaseURL != expect.baseURL {\n\t\t\tt.Fatalf(\"GLM route 不符合预期: %+v\", route)\n\t\t}\n\t\tif route.Candidates[0].PlanTag != \"payg\" {\n\t\t\tt.Fatalf(\"GLM route 应标记为 payg: %+v\", route.Candidates[0])\n\t\t}\n\t\tif route.ChannelKind != \"messages\" && (route.Candidates[1].PlanTag != \"coding_plan\" || !strings.Contains(route.Candidates[1].BaseURL, \"/api/coding/\")) {\n\t\t\tt.Fatalf(\"GLM %s route 应包含 Coding Plan 候选: %+v\", route.ChannelKind, route.Candidates)\n\t\t}\n\t}\n}\n\nfunc TestInferProviderIDFromBaseURL(t *testing.T) {\n\ttests := []struct {\n\t\tbaseURL string\n\t\twant string\n\t\tok bool\n\t}{\n\t\t{baseURL: \"https://api.deepseek.com\", want: \"deepseek\", ok: true},\n\t\t{baseURL: \"https://api.deepseek.com/anthropic/v1\", want: \"deepseek\", ok: true},\n\t\t{baseURL: \"https://ark.cn-beijing.volces.com/api/plan/v3\", want: \"volcengine\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/anthropic\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/anthropic/v1/messages\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/paas/v4/\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/paas/v4/chat/completions\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://opencode.ai/zen/go/v1/chat/completions\", want: \"opencode-zen\", ok: true},\n\t\t{baseURL: \"https://opencode.ai/zen/v1\", want: \"opencode-zen\", ok: true},\n\t\t{baseURL: \"https://opencode.ai.evil.example/zen/v1\", ok: false},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/other\", ok: false},\n\t\t{baseURL: \"https://open.bigmodel.cn.evil.example/api/paas/v4\", ok: false},\n\t\t{baseURL: \"https://relay.example/v1\", ok: false},\n\t\t{baseURL: \"https://api.deepseek.com.evil.example\", ok: false},\n\t}\n\tfor _, tt := range tests {\n\t\tgot, ok := InferProviderIDFromBaseURL(tt.baseURL)\n\t\tif got != tt.want || ok != tt.ok {\n\t\t\tt.Fatalf(\"InferProviderIDFromBaseURL(%q) = %q, %v; want %q, %v\", tt.baseURL, got, ok, tt.want, tt.ok)\n\t\t}\n\t}\n}\n\nfunc TestInferProviderIDFromAPIKey(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tapiKey string\n\t\twant string\n\t\tok bool\n\t}{\n\t\t{name: \"zhipu id.secret\", apiKey: \"0123456789abcdef0123456789abcdef.ABCDEFGHIJKLMNO1\", want: \"glm\", ok: true},\n\t\t{name: \"trim spaces\", apiKey: \" 269abc123456789012345678.r8abcdef1234 \", want: \"glm\", ok: true},\n\t\t{name: \"shared sk prefix\", apiKey: \"sk-abcdefghijklmnopqrstuvwxyz123456\", ok: false},\n\t\t{name: \"short dotted token\", apiKey: \"abc.def\", ok: false},\n\t\t{name: \"jwt\", apiKey: \"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature\", ok: false},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, ok := InferProviderIDFromAPIKey(tt.apiKey)\n\t\t\tif got != tt.want || ok != tt.ok {\n\t\t\t\tt.Fatalf(\"InferProviderIDFromAPIKey() = %q, %v; want %q, %v\", got, ok, tt.want, tt.ok)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestProviderTemplateVolcenginePlanRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"volcengine\")\n\tif !ok {\n\t\tt.Fatal(\"未找到火山方舟模板\")\n\t}\n\tif tmpl.DisplayName != \"火山方舟 Agent/Coding Plan\" || len(tmpl.AutoAddRoutes()) != 4 {\n\t\tt.Fatalf(\"火山模板不完整: %+v\", tmpl)\n\t}\n\twantBases := map[string][2]string{\n\t\t\"messages\": {\"https://ark.cn-beijing.volces.com/api/plan\", \"https://ark.cn-beijing.volces.com/api/coding\"},\n\t\t\"chat\": {\"https://ark.cn-beijing.volces.com/api/plan/v3\", \"https://ark.cn-beijing.volces.com/api/coding/v3\"},\n\t\t\"responses\": {\"https://ark.cn-beijing.volces.com/api/plan/v3\", \"https://ark.cn-beijing.volces.com/api/coding/v3\"},\n\t\t\"gemini\": {\"https://ark.cn-beijing.volces.com/api/plan/v3\", \"https://ark.cn-beijing.volces.com/api/coding/v3\"},\n\t}\n\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\tcandidates := tmpl.CandidatesForRouteKey(route, \"ark-test\")\n\t\tif len(candidates) != 2 {\n\t\t\tt.Fatalf(\"route %s 应包含 Agent/Coding 两个候选: %+v\", route.ChannelKind, candidates)\n\t\t}\n\t\twant, exists := wantBases[route.ChannelKind]\n\t\tif !exists || candidates[0].BaseURL != want[0] || candidates[1].BaseURL != want[1] {\n\t\t\tt.Fatalf(\"route %s Base URL 错误: got=%q/%q want=%q/%q\", route.ChannelKind,\n\t\t\t\tcandidates[0].BaseURL, candidates[1].BaseURL, want[0], want[1])\n\t\t}\n\t}\n}\n\nfunc TestListAndGetProviderTemplate(t *testing.T) {\n\tall := ListProviderTemplates()\n\tif len(all) < 16 {\n\t\tt.Errorf(\"内置 provider 模板应至少为 16 个,实际 %d\", len(all))\n\t}\n\tfor _, id := range []string{\n\t\t\"mimo\", \"deepseek\", \"kimi\", \"glm\", \"volcengine\", \"compshare\", \"sensenova\", \"minimax\",\n\t\t\"dashscope\", \"opencode-zen\", \"tencent-lkeap\", \"qianfan\", \"xfyun\", \"openrouter\", \"modelscope\", \"originrouter\",\n\t} {\n\t\tif _, ok := GetProviderTemplate(id); !ok {\n\t\t\tt.Errorf(\"缺少 provider 模板: %s\", id)\n\t\t}\n\t}\n\tfor alias, canonical := range map[string]string{\n\t\t\" opencode-go \": \"opencode-zen\",\n\t\t\"VOLC-ARK\": \"volcengine\",\n\t\t\"kimi-code\": \"kimi\",\n\t} {\n\t\ttmpl, ok := GetProviderTemplate(alias)\n\t\tif !ok || tmpl.ProviderID != canonical {\n\t\t\tt.Errorf(\"provider 别名 %q 应解析为 %q,实际 %+v\", alias, canonical, tmpl)\n\t\t}\n\t}\n\tfor _, id := range []string{\"runapi\", \"unity2\"} {\n\t\tif _, ok := GetProviderTemplate(id); ok {\n\t\t\tt.Errorf(\"中转站 %s 不应进入自动识别模板\", id)\n\t\t}\n\t}\n\tif _, ok := GetProviderTemplate(\"nonexistent\"); ok {\n\t\tt.Error(\"不存在的 providerId 应返回 false\")\n\t}\n}\n\nfunc TestProviderTemplateCompshareModelCostMultipliers(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"compshare\")\n\tif !ok {\n\t\tt.Fatal(\"未找到优云智算模板\")\n\t}\n\n\twant := map[string]float64{\n\t\t\"glm-5.2\": 2,\n\t\t\"glm-5.1\": 6,\n\t\t\"minimax-m2.7\": 2,\n\t\t\"kimi-k2.6\": 5,\n\t\t\"deepseek-ai/deepseek-v3.2\": 1,\n\t\t\"deepseek-v4-flash\": 1,\n\t}\n\tfor modelID, expected := range want {\n\t\tgot, found := tmpl.ModelCostMultiplierForModel(strings.ToUpper(modelID))\n\t\tif !found || got != expected {\n\t\t\tt.Errorf(\"模型 %s 的优云倍率 = %v, %v;期望 %v\", modelID, got, found, expected)\n\t\t}\n\t}\n\tif _, found := tmpl.ModelCostMultiplierForModel(\"unknown-model\"); found {\n\t\tt.Error(\"未知优云模型不应返回成本倍率\")\n\t}\n}\n\nfunc TestProviderTemplateModelCostMultiplierPrefersSpecificPattern(t *testing.T) {\n\ttmpl := ProviderTemplate{ModelCostMultipliers: map[string]float64{\n\t\t\"deepseek-*\": 3,\n\t\t\"deepseek-v4-*\": 1,\n\t}}\n\tgot, found := tmpl.ModelCostMultiplierForModel(\"DeepSeek-V4-Flash\")\n\tif !found || got != 1 {\n\t\tt.Fatalf(\"最长通配符倍率 = %v, %v;期望 1\", got, found)\n\t}\n}\n\nfunc TestEveryProviderCandidateCanBeInferred(t *testing.T) {\n\tfor _, tmpl := range ListProviderTemplates() {\n\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\tif len(route.Candidates) == 0 {\n\t\t\t\tt.Errorf(\"provider %s route %s 没有候选端点\", tmpl.ProviderID, route.ChannelKind)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, candidate := range route.Candidates {\n\t\t\t\tbaseURL := strings.TrimSuffix(candidate.BaseURL, \"#\")\n\t\t\t\tgot, ok := InferProviderIDFromBaseURL(baseURL)\n\t\t\t\tif !ok || got != tmpl.ProviderID {\n\t\t\t\t\tt.Errorf(\"候选端点 %q 反向识别为 %q, %v;期望 %q\", candidate.BaseURL, got, ok, tmpl.ProviderID)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestProviderTemplateOpenCodeRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"opencode-go\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 OpenCode 模板\")\n\t}\n\tif tmpl.ProviderID != \"opencode-zen\" || len(tmpl.AutoAddRoutes()) != 3 {\n\t\tt.Fatalf(\"OpenCode 模板不完整: %+v\", tmpl)\n\t}\n\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\tif route.ServiceType != \"openai\" || len(route.Candidates) != 2 {\n\t\t\tt.Fatalf(\"OpenCode %s route 应通过 OpenAI Chat 上游探测 Zen/Go 两个入口: %+v\", route.ChannelKind, route)\n\t\t}\n\t\tif route.Candidates[0].BaseURL != \"https://opencode.ai/zen/go/v1\" || route.Candidates[1].BaseURL != \"https://opencode.ai/zen/v1\" {\n\t\t\tt.Fatalf(\"OpenCode 候选顺序错误: %+v\", route.Candidates)\n\t\t}\n\t}\n}\n\nfunc TestProviderTemplateXFyunNativeResponsesRoute(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"xfyun\")\n\tif !ok {\n\t\tt.Fatal(\"未找到讯飞模板\")\n\t}\n\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\tif route.ChannelKind != \"responses\" {\n\t\t\tcontinue\n\t\t}\n\t\tif route.ServiceType != \"responses\" || len(route.Candidates) != 1 || !strings.HasSuffix(route.Candidates[0].BaseURL, \"/v1/responses\") {\n\t\t\tt.Fatalf(\"讯飞 Responses route 不符合预期: %+v\", route)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatal(\"讯飞模板缺少原生 Responses route\")\n}\n\nfunc TestProviderTemplatesDoNotExposeChannelPresetRefs(t *testing.T) {\n\tdata, err := json.Marshal(ListProviderTemplates())\n\tif err != nil {\n\t\tt.Fatalf(\"序列化 provider templates 失败: %v\", err)\n\t}\n\tfor _, field := range [][]byte{[]byte(\"presetRef\"), []byte(\"presetCollection\")} {\n\t\tif bytes.Contains(data, field) {\n\t\t\tt.Fatalf(\"provider template 不应暴露 channel preset 字段 %q: %s\", field, data)\n\t\t}\n\t}\n}\n"}, "e5072bc9c1f13328cd963b5ca0bf2434075f2647": {"t": 1785759323.7144663, "n": 11813, "h": "04323b86b47b09c6", "c": "package config\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestCandidatesForKeyPrefix(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"mimo\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 mimo 模板\")\n\t}\n\n\t// sk- 前缀 → payg 优先\n\tskCands := tmpl.CandidatesForKey(\"sk-abc\")\n\tif len(skCands) == 0 || skCands[0].PlanTag != \"payg\" {\n\t\tt.Errorf(\"sk- key 首选应为 payg,实际 %+v\", skCands)\n\t}\n\n\t// tp- 前缀 → token_plan 优先,且按 Priority(cn=0 先)\n\ttpCands := tmpl.CandidatesForKey(\"tp-xyz\")\n\tif len(tpCands) == 0 || tpCands[0].PlanTag != \"token_plan\" || tpCands[0].Region != \"cn\" {\n\t\tt.Errorf(\"tp- key 首选应为 token_plan/cn,实际 %+v\", tpCands)\n\t}\n\t// tp- 回退候选应包含 payg(全部候选都在)\n\tif len(tpCands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"tp- key 应返回全部候选(含回退)%d,实际 %d\", len(tmpl.Candidates), len(tpCands))\n\t}\n\n\t// 无匹配前缀 → 返回全部候选\n\tunknownCands := tmpl.CandidatesForKey(\"xx-none\")\n\tif len(unknownCands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"未匹配前缀应返回全部候选 %d,实际 %d\", len(tmpl.Candidates), len(unknownCands))\n\t}\n}\n\nfunc TestProviderTemplateMiMoRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"mimo\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 mimo 模板\")\n\t}\n\troutes := tmpl.AutoAddRoutes()\n\tif len(routes) != 4 {\n\t\tt.Fatalf(\"mimo 应创建 4 条 route,实际 %d: %+v\", len(routes), routes)\n\t}\n\n\twant := map[string]struct {\n\t\tserviceType string\n\t\tbaseSuffix string\n\t}{\n\t\t\"messages\": {serviceType: \"claude\", baseSuffix: \"/anthropic\"},\n\t\t\"chat\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t\t\"responses\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t\t\"gemini\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t}\n\tfor _, route := range routes {\n\t\texpect, ok := want[route.ChannelKind]\n\t\tif !ok {\n\t\t\tt.Fatalf(\"未知 route kind: %+v\", route)\n\t\t}\n\t\tif route.ServiceType != expect.serviceType {\n\t\t\tt.Fatalf(\"route %s serviceType=%s, want %s\", route.ChannelKind, route.ServiceType, expect.serviceType)\n\t\t}\n\t\tcandidates := tmpl.CandidatesForRouteKey(route, \"tp-test\")\n\t\tif len(candidates) == 0 {\n\t\t\tt.Fatalf(\"route %s 没有候选\", route.ChannelKind)\n\t\t}\n\t\tif !strings.HasSuffix(candidates[0].BaseURL, expect.baseSuffix) {\n\t\t\tt.Fatalf(\"route %s 首选 baseURL=%s, want suffix %s\", route.ChannelKind, candidates[0].BaseURL, expect.baseSuffix)\n\t\t}\n\t}\n\tif !tmpl.SupportsChannelKind(\"messages\") || !tmpl.SupportsChannelKind(\"chat\") || !tmpl.SupportsChannelKind(\"responses\") || !tmpl.SupportsChannelKind(\"gemini\") {\n\t\tt.Fatalf(\"mimo 应支持 messages/chat/responses/gemini routes: %+v\", routes)\n\t}\n}\n\nfunc TestCandidatesForKeyNoPrefixRules(t *testing.T) {\n\t// deepseek 无前缀规则,任意 key 返回全部候选\n\ttmpl, ok := GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 deepseek 模板\")\n\t}\n\tcands := tmpl.CandidatesForKey(\"anything\")\n\tif len(cands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"无前缀规则应返回全部候选 %d,实际 %d\", len(tmpl.Candidates), len(cands))\n\t}\n}\n\nfunc TestProviderTemplateDeepSeekRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 deepseek 模板\")\n\t}\n\troutes := tmpl.AutoAddRoutes()\n\tif len(routes) != 3 {\n\t\tt.Fatalf(\"deepseek 应创建 messages/chat/responses 三条 route,实际 %d: %+v\", len(routes), routes)\n\t}\n\twant := map[string]struct {\n\t\tserviceType string\n\t\tbaseURL string\n\t}{\n\t\t\"messages\": {serviceType: \"claude\", baseURL: \"https://api.deepseek.com/anthropic\"},\n\t\t\"chat\": {serviceType: \"openai\", baseURL: \"https://api.deepseek.com\"},\n\t\t\"responses\": {serviceType: \"openai\", baseURL: \"https://api.deepseek.com\"},\n\t}\n\tfor _, route := range routes {\n\t\texpect, found := want[route.ChannelKind]\n\t\tif !found || route.ServiceType != expect.serviceType || len(route.Candidates) != 1 || route.Candidates[0].BaseURL != expect.baseURL {\n\t\t\tt.Fatalf(\"DeepSeek route 不符合预期: %+v\", route)\n\t\t}\n\t}\n}\n\nfunc TestProviderTemplateGLMRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"glm\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 GLM 模板\")\n\t}\n\troutes := tmpl.AutoAddRoutes()\n\tif len(routes) != 3 {\n\t\tt.Fatalf(\"GLM 应创建 messages/chat/responses 三条 route,实际 %d: %+v\", len(routes), routes)\n\t}\n\twant := map[string]struct {\n\t\tserviceType string\n\t\tbaseURL string\n\t\tcandidates int\n\t}{\n\t\t\"messages\": {serviceType: \"claude\", baseURL: \"https://open.bigmodel.cn/api/anthropic\", candidates: 1},\n\t\t\"chat\": {serviceType: \"openai\", baseURL: \"https://open.bigmodel.cn/api/paas/v4#\", candidates: 2},\n\t\t\"responses\": {serviceType: \"openai\", baseURL: \"https://open.bigmodel.cn/api/paas/v4#\", candidates: 2},\n\t}\n\tfor _, route := range routes {\n\t\texpect, found := want[route.ChannelKind]\n\t\tif !found || route.ServiceType != expect.serviceType || len(route.Candidates) != expect.candidates || route.Candidates[0].BaseURL != expect.baseURL {\n\t\t\tt.Fatalf(\"GLM route 不符合预期: %+v\", route)\n\t\t}\n\t\tif route.Candidates[0].PlanTag != \"payg\" {\n\t\t\tt.Fatalf(\"GLM route 应标记为 payg: %+v\", route.Candidates[0])\n\t\t}\n\t\tif route.ChannelKind != \"messages\" && (route.Candidates[1].PlanTag != \"coding_plan\" || !strings.Contains(route.Candidates[1].BaseURL, \"/api/coding/\")) {\n\t\t\tt.Fatalf(\"GLM %s route 应包含 Coding Plan 候选: %+v\", route.ChannelKind, route.Candidates)\n\t\t}\n\t}\n}\n\nfunc TestInferProviderIDFromBaseURL(t *testing.T) {\n\ttests := []struct {\n\t\tbaseURL string\n\t\twant string\n\t\tok bool\n\t}{\n\t\t{baseURL: \"https://api.deepseek.com\", want: \"deepseek\", ok: true},\n\t\t{baseURL: \"https://api.deepseek.com/anthropic/v1\", want: \"deepseek\", ok: true},\n\t\t{baseURL: \"https://ark.cn-beijing.volces.com/api/plan/v3\", want: \"volcengine\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/anthropic\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/anthropic/v1/messages\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/paas/v4/\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/paas/v4/chat/completions\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://opencode.ai/zen/go/v1/chat/completions\", want: \"opencode-zen\", ok: true},\n\t\t{baseURL: \"https://opencode.ai/zen/v1\", want: \"opencode-zen\", ok: true},\n\t\t{baseURL: \"https://opencode.ai.evil.example/zen/v1\", ok: false},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/other\", ok: false},\n\t\t{baseURL: \"https://open.bigmodel.cn.evil.example/api/paas/v4\", ok: false},\n\t\t{baseURL: \"https://relay.example/v1\", ok: false},\n\t\t{baseURL: \"https://api.deepseek.com.evil.example\", ok: false},\n\t}\n\tfor _, tt := range tests {\n\t\tgot, ok := InferProviderIDFromBaseURL(tt.baseURL)\n\t\tif got != tt.want || ok != tt.ok {\n\t\t\tt.Fatalf(\"InferProviderIDFromBaseURL(%q) = %q, %v; want %q, %v\", tt.baseURL, got, ok, tt.want, tt.ok)\n\t\t}\n\t}\n}\n\nfunc TestInferProviderIDFromAPIKey(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tapiKey string\n\t\twant string\n\t\tok bool\n\t}{\n\t\t{name: \"zhipu id.secret\", apiKey: \"0123456789abcdef0123456789abcdef.ABCDEFGHIJKLMNO1\", want: \"glm\", ok: true},\n\t\t{name: \"trim spaces\", apiKey: \" 269abc123456789012345678.r8abcdef1234 \", want: \"glm\", ok: true},\n\t\t{name: \"shared sk prefix\", apiKey: \"sk-abcdefghijklmnopqrstuvwxyz123456\", ok: false},\n\t\t{name: \"short dotted token\", apiKey: \"abc.def\", ok: false},\n\t\t{name: \"jwt\", apiKey: \"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature\", ok: false},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, ok := InferProviderIDFromAPIKey(tt.apiKey)\n\t\t\tif got != tt.want || ok != tt.ok {\n\t\t\t\tt.Fatalf(\"InferProviderIDFromAPIKey() = %q, %v; want %q, %v\", got, ok, tt.want, tt.ok)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestProviderTemplateVolcenginePlanRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"volcengine\")\n\tif !ok {\n\t\tt.Fatal(\"未找到火山方舟模板\")\n\t}\n\tif tmpl.DisplayName != \"火山方舟 Agent/Coding Plan\" || len(tmpl.AutoAddRoutes()) != 4 {\n\t\tt.Fatalf(\"火山模板不完整: %+v\", tmpl)\n\t}\n\twantBases := map[string][2]string{\n\t\t\"messages\": {\"https://ark.cn-beijing.volces.com/api/plan\", \"https://ark.cn-beijing.volces.com/api/coding\"},\n\t\t\"chat\": {\"https://ark.cn-beijing.volces.com/api/plan/v3\", \"https://ark.cn-beijing.volces.com/api/coding/v3\"},\n\t\t\"responses\": {\"https://ark.cn-beijing.volces.com/api/plan/v3\", \"https://ark.cn-beijing.volces.com/api/coding/v3\"},\n\t\t\"gemini\": {\"https://ark.cn-beijing.volces.com/api/plan/v3\", \"https://ark.cn-beijing.volces.com/api/coding/v3\"},\n\t}\n\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\tcandidates := tmpl.CandidatesForRouteKey(route, \"ark-test\")\n\t\tif len(candidates) != 2 {\n\t\t\tt.Fatalf(\"route %s 应包含 Agent/Coding 两个候选: %+v\", route.ChannelKind, candidates)\n\t\t}\n\t\twant, exists := wantBases[route.ChannelKind]\n\t\tif !exists || candidates[0].BaseURL != want[0] || candidates[1].BaseURL != want[1] {\n\t\t\tt.Fatalf(\"route %s Base URL 错误: got=%q/%q want=%q/%q\", route.ChannelKind,\n\t\t\t\tcandidates[0].BaseURL, candidates[1].BaseURL, want[0], want[1])\n\t\t}\n\t}\n}\n\nfunc TestListAndGetProviderTemplate(t *testing.T) {\n\tall := ListProviderTemplates()\n\tif len(all) < 16 {\n\t\tt.Errorf(\"内置 provider 模板应至少为 16 个,实际 %d\", len(all))\n\t}\n\tfor _, id := range []string{\n\t\t\"mimo\", \"deepseek\", \"kimi\", \"glm\", \"volcengine\", \"compshare\", \"sensenova\", \"minimax\",\n\t\t\"dashscope\", \"opencode-zen\", \"tencent-lkeap\", \"qianfan\", \"xfyun\", \"openrouter\", \"modelscope\", \"originrouter\",\n\t} {\n\t\tif _, ok := GetProviderTemplate(id); !ok {\n\t\t\tt.Errorf(\"缺少 provider 模板: %s\", id)\n\t\t}\n\t}\n\tfor alias, canonical := range map[string]string{\n\t\t\" opencode-go \": \"opencode-zen\",\n\t\t\"VOLC-ARK\": \"volcengine\",\n\t\t\"kimi-code\": \"kimi\",\n\t} {\n\t\ttmpl, ok := GetProviderTemplate(alias)\n\t\tif !ok || tmpl.ProviderID != canonical {\n\t\t\tt.Errorf(\"provider 别名 %q 应解析为 %q,实际 %+v\", alias, canonical, tmpl)\n\t\t}\n\t}\n\tfor _, id := range []string{\"runapi\", \"unity2\"} {\n\t\tif _, ok := GetProviderTemplate(id); ok {\n\t\t\tt.Errorf(\"中转站 %s 不应进入自动识别模板\", id)\n\t\t}\n\t}\n\tif _, ok := GetProviderTemplate(\"nonexistent\"); ok {\n\t\tt.Error(\"不存在的 providerId 应返回 false\")\n\t}\n}\n\nfunc TestEveryProviderCandidateCanBeInferred(t *testing.T) {\n\tfor _, tmpl := range ListProviderTemplates() {\n\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\tif len(route.Candidates) == 0 {\n\t\t\t\tt.Errorf(\"provider %s route %s 没有候选端点\", tmpl.ProviderID, route.ChannelKind)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, candidate := range route.Candidates {\n\t\t\t\tbaseURL := strings.TrimSuffix(candidate.BaseURL, \"#\")\n\t\t\t\tgot, ok := InferProviderIDFromBaseURL(baseURL)\n\t\t\t\tif !ok || got != tmpl.ProviderID {\n\t\t\t\t\tt.Errorf(\"候选端点 %q 反向识别为 %q, %v;期望 %q\", candidate.BaseURL, got, ok, tmpl.ProviderID)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestProviderTemplateOpenCodeRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"opencode-go\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 OpenCode 模板\")\n\t}\n\tif tmpl.ProviderID != \"opencode-zen\" || len(tmpl.AutoAddRoutes()) != 3 {\n\t\tt.Fatalf(\"OpenCode 模板不完整: %+v\", tmpl)\n\t}\n\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\tif route.ServiceType != \"openai\" || len(route.Candidates) != 2 {\n\t\t\tt.Fatalf(\"OpenCode %s route 应通过 OpenAI Chat 上游探测 Zen/Go 两个入口: %+v\", route.ChannelKind, route)\n\t\t}\n\t\tif route.Candidates[0].BaseURL != \"https://opencode.ai/zen/go/v1\" || route.Candidates[1].BaseURL != \"https://opencode.ai/zen/v1\" {\n\t\t\tt.Fatalf(\"OpenCode 候选顺序错误: %+v\", route.Candidates)\n\t\t}\n\t}\n}\n\nfunc TestProviderTemplateXFyunNativeResponsesRoute(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"xfyun\")\n\tif !ok {\n\t\tt.Fatal(\"未找到讯飞模板\")\n\t}\n\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\tif route.ChannelKind != \"responses\" {\n\t\t\tcontinue\n\t\t}\n\t\tif route.ServiceType != \"responses\" || len(route.Candidates) != 1 || !strings.HasSuffix(route.Candidates[0].BaseURL, \"/v1/responses\") {\n\t\t\tt.Fatalf(\"讯飞 Responses route 不符合预期: %+v\", route)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatal(\"讯飞模板缺少原生 Responses route\")\n}\n\nfunc TestProviderTemplatesDoNotExposeChannelPresetRefs(t *testing.T) {\n\tdata, err := json.Marshal(ListProviderTemplates())\n\tif err != nil {\n\t\tt.Fatalf(\"序列化 provider templates 失败: %v\", err)\n\t}\n\tfor _, field := range [][]byte{[]byte(\"presetRef\"), []byte(\"presetCollection\")} {\n\t\tif bytes.Contains(data, field) {\n\t\t\tt.Fatalf(\"provider template 不应暴露 channel preset 字段 %q: %s\", field, data)\n\t\t}\n\t}\n}\n"}, "c37b88af3f461fcddd8d23112b865c6c08dee729": {"t": 1785759325.7793236, "n": 11220, "h": "d57a30198ce67187", "c": "package config\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestCandidatesForKeyPrefix(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"mimo\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 mimo 模板\")\n\t}\n\n\t// sk- 前缀 → payg 优先\n\tskCands := tmpl.CandidatesForKey(\"sk-abc\")\n\tif len(skCands) == 0 || skCands[0].PlanTag != \"payg\" {\n\t\tt.Errorf(\"sk- key 首选应为 payg,实际 %+v\", skCands)\n\t}\n\n\t// tp- 前缀 → token_plan 优先,且按 Priority(cn=0 先)\n\ttpCands := tmpl.CandidatesForKey(\"tp-xyz\")\n\tif len(tpCands) == 0 || tpCands[0].PlanTag != \"token_plan\" || tpCands[0].Region != \"cn\" {\n\t\tt.Errorf(\"tp- key 首选应为 token_plan/cn,实际 %+v\", tpCands)\n\t}\n\t// tp- 回退候选应包含 payg(全部候选都在)\n\tif len(tpCands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"tp- key 应返回全部候选(含回退)%d,实际 %d\", len(tmpl.Candidates), len(tpCands))\n\t}\n\n\t// 无匹配前缀 → 返回全部候选\n\tunknownCands := tmpl.CandidatesForKey(\"xx-none\")\n\tif len(unknownCands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"未匹配前缀应返回全部候选 %d,实际 %d\", len(tmpl.Candidates), len(unknownCands))\n\t}\n}\n\nfunc TestProviderTemplateMiMoRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"mimo\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 mimo 模板\")\n\t}\n\troutes := tmpl.AutoAddRoutes()\n\tif len(routes) != 4 {\n\t\tt.Fatalf(\"mimo 应创建 4 条 route,实际 %d: %+v\", len(routes), routes)\n\t}\n\n\twant := map[string]struct {\n\t\tserviceType string\n\t\tbaseSuffix string\n\t}{\n\t\t\"messages\": {serviceType: \"claude\", baseSuffix: \"/anthropic\"},\n\t\t\"chat\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t\t\"responses\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t\t\"gemini\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t}\n\tfor _, route := range routes {\n\t\texpect, ok := want[route.ChannelKind]\n\t\tif !ok {\n\t\t\tt.Fatalf(\"未知 route kind: %+v\", route)\n\t\t}\n\t\tif route.ServiceType != expect.serviceType {\n\t\t\tt.Fatalf(\"route %s serviceType=%s, want %s\", route.ChannelKind, route.ServiceType, expect.serviceType)\n\t\t}\n\t\tcandidates := tmpl.CandidatesForRouteKey(route, \"tp-test\")\n\t\tif len(candidates) == 0 {\n\t\t\tt.Fatalf(\"route %s 没有候选\", route.ChannelKind)\n\t\t}\n\t\tif !strings.HasSuffix(candidates[0].BaseURL, expect.baseSuffix) {\n\t\t\tt.Fatalf(\"route %s 首选 baseURL=%s, want suffix %s\", route.ChannelKind, candidates[0].BaseURL, expect.baseSuffix)\n\t\t}\n\t}\n\tif !tmpl.SupportsChannelKind(\"messages\") || !tmpl.SupportsChannelKind(\"chat\") || !tmpl.SupportsChannelKind(\"responses\") || !tmpl.SupportsChannelKind(\"gemini\") {\n\t\tt.Fatalf(\"mimo 应支持 messages/chat/responses/gemini routes: %+v\", routes)\n\t}\n}\n\nfunc TestCandidatesForKeyNoPrefixRules(t *testing.T) {\n\t// deepseek 无前缀规则,任意 key 返回全部候选\n\ttmpl, ok := GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 deepseek 模板\")\n\t}\n\tcands := tmpl.CandidatesForKey(\"anything\")\n\tif len(cands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"无前缀规则应返回全部候选 %d,实际 %d\", len(tmpl.Candidates), len(cands))\n\t}\n}\n\nfunc TestProviderTemplateDeepSeekRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 deepseek 模板\")\n\t}\n\troutes := tmpl.AutoAddRoutes()\n\tif len(routes) != 3 {\n\t\tt.Fatalf(\"deepseek 应创建 messages/chat/responses 三条 route,实际 %d: %+v\", len(routes), routes)\n\t}\n\twant := map[string]struct {\n\t\tserviceType string\n\t\tbaseURL string\n\t}{\n\t\t\"messages\": {serviceType: \"claude\", baseURL: \"https://api.deepseek.com/anthropic\"},\n\t\t\"chat\": {serviceType: \"openai\", baseURL: \"https://api.deepseek.com\"},\n\t\t\"responses\": {serviceType: \"openai\", baseURL: \"https://api.deepseek.com\"},\n\t}\n\tfor _, route := range routes {\n\t\texpect, found := want[route.ChannelKind]\n\t\tif !found || route.ServiceType != expect.serviceType || len(route.Candidates) != 1 || route.Candidates[0].BaseURL != expect.baseURL {\n\t\t\tt.Fatalf(\"DeepSeek route 不符合预期: %+v\", route)\n\t\t}\n\t}\n}\n\nfunc TestProviderTemplateGLMRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"glm\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 GLM 模板\")\n\t}\n\troutes := tmpl.AutoAddRoutes()\n\tif len(routes) != 3 {\n\t\tt.Fatalf(\"GLM 应创建 messages/chat/responses 三条 route,实际 %d: %+v\", len(routes), routes)\n\t}\n\twant := map[string]struct {\n\t\tserviceType string\n\t\tbaseURL string\n\t\tcandidates int\n\t}{\n\t\t\"messages\": {serviceType: \"claude\", baseURL: \"https://open.bigmodel.cn/api/anthropic\", candidates: 1},\n\t\t\"chat\": {serviceType: \"openai\", baseURL: \"https://open.bigmodel.cn/api/paas/v4#\", candidates: 2},\n\t\t\"responses\": {serviceType: \"openai\", baseURL: \"https://open.bigmodel.cn/api/paas/v4#\", candidates: 2},\n\t}\n\tfor _, route := range routes {\n\t\texpect, found := want[route.ChannelKind]\n\t\tif !found || route.ServiceType != expect.serviceType || len(route.Candidates) != expect.candidates || route.Candidates[0].BaseURL != expect.baseURL {\n\t\t\tt.Fatalf(\"GLM route 不符合预期: %+v\", route)\n\t\t}\n\t\tif route.Candidates[0].PlanTag != \"payg\" {\n\t\t\tt.Fatalf(\"GLM route 应标记为 payg: %+v\", route.Candidates[0])\n\t\t}\n\t\tif route.ChannelKind != \"messages\" && (route.Candidates[1].PlanTag != \"coding_plan\" || !strings.Contains(route.Candidates[1].BaseURL, \"/api/coding/\")) {\n\t\t\tt.Fatalf(\"GLM %s route 应包含 Coding Plan 候选: %+v\", route.ChannelKind, route.Candidates)\n\t\t}\n\t}\n}\n\nfunc TestInferProviderIDFromBaseURL(t *testing.T) {\n\ttests := []struct {\n\t\tbaseURL string\n\t\twant string\n\t\tok bool\n\t}{\n\t\t{baseURL: \"https://api.deepseek.com\", want: \"deepseek\", ok: true},\n\t\t{baseURL: \"https://api.deepseek.com/anthropic/v1\", want: \"deepseek\", ok: true},\n\t\t{baseURL: \"https://ark.cn-beijing.volces.com/api/plan/v3\", want: \"volcengine\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/anthropic\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/anthropic/v1/messages\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/paas/v4/\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/paas/v4/chat/completions\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://opencode.ai/zen/go/v1/chat/completions\", want: \"opencode-zen\", ok: true},\n\t\t{baseURL: \"https://opencode.ai/zen/v1\", want: \"opencode-zen\", ok: true},\n\t\t{baseURL: \"https://opencode.ai.evil.example/zen/v1\", ok: false},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/other\", ok: false},\n\t\t{baseURL: \"https://open.bigmodel.cn.evil.example/api/paas/v4\", ok: false},\n\t\t{baseURL: \"https://relay.example/v1\", ok: false},\n\t\t{baseURL: \"https://api.deepseek.com.evil.example\", ok: false},\n\t}\n\tfor _, tt := range tests {\n\t\tgot, ok := InferProviderIDFromBaseURL(tt.baseURL)\n\t\tif got != tt.want || ok != tt.ok {\n\t\t\tt.Fatalf(\"InferProviderIDFromBaseURL(%q) = %q, %v; want %q, %v\", tt.baseURL, got, ok, tt.want, tt.ok)\n\t\t}\n\t}\n}\n\nfunc TestInferProviderIDFromAPIKey(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tapiKey string\n\t\twant string\n\t\tok bool\n\t}{\n\t\t{name: \"zhipu id.secret\", apiKey: \"0123456789abcdef0123456789abcdef.ABCDEFGHIJKLMNO1\", want: \"glm\", ok: true},\n\t\t{name: \"trim spaces\", apiKey: \" 269abc123456789012345678.r8abcdef1234 \", want: \"glm\", ok: true},\n\t\t{name: \"shared sk prefix\", apiKey: \"sk-abcdefghijklmnopqrstuvwxyz123456\", ok: false},\n\t\t{name: \"short dotted token\", apiKey: \"abc.def\", ok: false},\n\t\t{name: \"jwt\", apiKey: \"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature\", ok: false},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, ok := InferProviderIDFromAPIKey(tt.apiKey)\n\t\t\tif got != tt.want || ok != tt.ok {\n\t\t\t\tt.Fatalf(\"InferProviderIDFromAPIKey() = %q, %v; want %q, %v\", got, ok, tt.want, tt.ok)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestProviderTemplateVolcenginePlanRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"volcengine\")\n\tif !ok {\n\t\tt.Fatal(\"未找到火山方舟模板\")\n\t}\n\tif tmpl.DisplayName != \"火山方舟 Agent/Coding Plan\" || len(tmpl.AutoAddRoutes()) != 4 {\n\t\tt.Fatalf(\"火山模板不完整: %+v\", tmpl)\n\t}\n\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\tcandidates := tmpl.CandidatesForRouteKey(route, \"ark-test\")\n\t\tif len(candidates) != 2 {\n\t\t\tt.Fatalf(\"route %s 应包含 Agent/Coding 两个候选: %+v\", route.ChannelKind, candidates)\n\t\t}\n\t\tif !strings.Contains(candidates[0].BaseURL, \"/api/plan\") || !strings.Contains(candidates[1].BaseURL, \"/api/coding\") {\n\t\t\tt.Fatalf(\"route %s 候选顺序错误: %+v\", route.ChannelKind, candidates)\n\t\t}\n\t}\n}\n\nfunc TestListAndGetProviderTemplate(t *testing.T) {\n\tall := ListProviderTemplates()\n\tif len(all) < 16 {\n\t\tt.Errorf(\"内置 provider 模板应至少为 16 个,实际 %d\", len(all))\n\t}\n\tfor _, id := range []string{\n\t\t\"mimo\", \"deepseek\", \"kimi\", \"glm\", \"volcengine\", \"compshare\", \"sensenova\", \"minimax\",\n\t\t\"dashscope\", \"opencode-zen\", \"tencent-lkeap\", \"qianfan\", \"xfyun\", \"openrouter\", \"modelscope\", \"originrouter\",\n\t} {\n\t\tif _, ok := GetProviderTemplate(id); !ok {\n\t\t\tt.Errorf(\"缺少 provider 模板: %s\", id)\n\t\t}\n\t}\n\tfor alias, canonical := range map[string]string{\n\t\t\" opencode-go \": \"opencode-zen\",\n\t\t\"VOLC-ARK\": \"volcengine\",\n\t\t\"kimi-code\": \"kimi\",\n\t} {\n\t\ttmpl, ok := GetProviderTemplate(alias)\n\t\tif !ok || tmpl.ProviderID != canonical {\n\t\t\tt.Errorf(\"provider 别名 %q 应解析为 %q,实际 %+v\", alias, canonical, tmpl)\n\t\t}\n\t}\n\tfor _, id := range []string{\"runapi\", \"unity2\"} {\n\t\tif _, ok := GetProviderTemplate(id); ok {\n\t\t\tt.Errorf(\"中转站 %s 不应进入自动识别模板\", id)\n\t\t}\n\t}\n\tif _, ok := GetProviderTemplate(\"nonexistent\"); ok {\n\t\tt.Error(\"不存在的 providerId 应返回 false\")\n\t}\n}\n\nfunc TestEveryProviderCandidateCanBeInferred(t *testing.T) {\n\tfor _, tmpl := range ListProviderTemplates() {\n\t\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\t\tif len(route.Candidates) == 0 {\n\t\t\t\tt.Errorf(\"provider %s route %s 没有候选端点\", tmpl.ProviderID, route.ChannelKind)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, candidate := range route.Candidates {\n\t\t\t\tbaseURL := strings.TrimSuffix(candidate.BaseURL, \"#\")\n\t\t\t\tgot, ok := InferProviderIDFromBaseURL(baseURL)\n\t\t\t\tif !ok || got != tmpl.ProviderID {\n\t\t\t\t\tt.Errorf(\"候选端点 %q 反向识别为 %q, %v;期望 %q\", candidate.BaseURL, got, ok, tmpl.ProviderID)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestProviderTemplateOpenCodeRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"opencode-go\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 OpenCode 模板\")\n\t}\n\tif tmpl.ProviderID != \"opencode-zen\" || len(tmpl.AutoAddRoutes()) != 3 {\n\t\tt.Fatalf(\"OpenCode 模板不完整: %+v\", tmpl)\n\t}\n\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\tif route.ServiceType != \"openai\" || len(route.Candidates) != 2 {\n\t\t\tt.Fatalf(\"OpenCode %s route 应通过 OpenAI Chat 上游探测 Zen/Go 两个入口: %+v\", route.ChannelKind, route)\n\t\t}\n\t\tif route.Candidates[0].BaseURL != \"https://opencode.ai/zen/go/v1\" || route.Candidates[1].BaseURL != \"https://opencode.ai/zen/v1\" {\n\t\t\tt.Fatalf(\"OpenCode 候选顺序错误: %+v\", route.Candidates)\n\t\t}\n\t}\n}\n\nfunc TestProviderTemplateXFyunNativeResponsesRoute(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"xfyun\")\n\tif !ok {\n\t\tt.Fatal(\"未找到讯飞模板\")\n\t}\n\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\tif route.ChannelKind != \"responses\" {\n\t\t\tcontinue\n\t\t}\n\t\tif route.ServiceType != \"responses\" || len(route.Candidates) != 1 || !strings.HasSuffix(route.Candidates[0].BaseURL, \"/v1/responses\") {\n\t\t\tt.Fatalf(\"讯飞 Responses route 不符合预期: %+v\", route)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatal(\"讯飞模板缺少原生 Responses route\")\n}\n\nfunc TestProviderTemplatesDoNotExposeChannelPresetRefs(t *testing.T) {\n\tdata, err := json.Marshal(ListProviderTemplates())\n\tif err != nil {\n\t\tt.Fatalf(\"序列化 provider templates 失败: %v\", err)\n\t}\n\tfor _, field := range [][]byte{[]byte(\"presetRef\"), []byte(\"presetCollection\")} {\n\t\tif bytes.Contains(data, field) {\n\t\t\tt.Fatalf(\"provider template 不应暴露 channel preset 字段 %q: %s\", field, data)\n\t\t}\n\t}\n}\n"}, "5743084bf3f7dc6abffbf9b41b81ce80f22d07fa": {"t": 1785759327.1687133, "n": 8188, "h": "4dbc8384635dce29", "c": "package config\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestCandidatesForKeyPrefix(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"mimo\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 mimo 模板\")\n\t}\n\n\t// sk- 前缀 → payg 优先\n\tskCands := tmpl.CandidatesForKey(\"sk-abc\")\n\tif len(skCands) == 0 || skCands[0].PlanTag != \"payg\" {\n\t\tt.Errorf(\"sk- key 首选应为 payg,实际 %+v\", skCands)\n\t}\n\n\t// tp- 前缀 → token_plan 优先,且按 Priority(cn=0 先)\n\ttpCands := tmpl.CandidatesForKey(\"tp-xyz\")\n\tif len(tpCands) == 0 || tpCands[0].PlanTag != \"token_plan\" || tpCands[0].Region != \"cn\" {\n\t\tt.Errorf(\"tp- key 首选应为 token_plan/cn,实际 %+v\", tpCands)\n\t}\n\t// tp- 回退候选应包含 payg(全部候选都在)\n\tif len(tpCands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"tp- key 应返回全部候选(含回退)%d,实际 %d\", len(tmpl.Candidates), len(tpCands))\n\t}\n\n\t// 无匹配前缀 → 返回全部候选\n\tunknownCands := tmpl.CandidatesForKey(\"xx-none\")\n\tif len(unknownCands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"未匹配前缀应返回全部候选 %d,实际 %d\", len(tmpl.Candidates), len(unknownCands))\n\t}\n}\n\nfunc TestProviderTemplateMiMoRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"mimo\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 mimo 模板\")\n\t}\n\troutes := tmpl.AutoAddRoutes()\n\tif len(routes) != 4 {\n\t\tt.Fatalf(\"mimo 应创建 4 条 route,实际 %d: %+v\", len(routes), routes)\n\t}\n\n\twant := map[string]struct {\n\t\tserviceType string\n\t\tbaseSuffix string\n\t}{\n\t\t\"messages\": {serviceType: \"claude\", baseSuffix: \"/anthropic\"},\n\t\t\"chat\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t\t\"responses\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t\t\"gemini\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t}\n\tfor _, route := range routes {\n\t\texpect, ok := want[route.ChannelKind]\n\t\tif !ok {\n\t\t\tt.Fatalf(\"未知 route kind: %+v\", route)\n\t\t}\n\t\tif route.ServiceType != expect.serviceType {\n\t\t\tt.Fatalf(\"route %s serviceType=%s, want %s\", route.ChannelKind, route.ServiceType, expect.serviceType)\n\t\t}\n\t\tcandidates := tmpl.CandidatesForRouteKey(route, \"tp-test\")\n\t\tif len(candidates) == 0 {\n\t\t\tt.Fatalf(\"route %s 没有候选\", route.ChannelKind)\n\t\t}\n\t\tif !strings.HasSuffix(candidates[0].BaseURL, expect.baseSuffix) {\n\t\t\tt.Fatalf(\"route %s 首选 baseURL=%s, want suffix %s\", route.ChannelKind, candidates[0].BaseURL, expect.baseSuffix)\n\t\t}\n\t}\n\tif !tmpl.SupportsChannelKind(\"messages\") || !tmpl.SupportsChannelKind(\"chat\") || !tmpl.SupportsChannelKind(\"responses\") || !tmpl.SupportsChannelKind(\"gemini\") {\n\t\tt.Fatalf(\"mimo 应支持 messages/chat/responses/gemini routes: %+v\", routes)\n\t}\n}\n\nfunc TestCandidatesForKeyNoPrefixRules(t *testing.T) {\n\t// deepseek 无前缀规则,任意 key 返回全部候选\n\ttmpl, ok := GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 deepseek 模板\")\n\t}\n\tcands := tmpl.CandidatesForKey(\"anything\")\n\tif len(cands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"无前缀规则应返回全部候选 %d,实际 %d\", len(tmpl.Candidates), len(cands))\n\t}\n}\n\nfunc TestProviderTemplateDeepSeekRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 deepseek 模板\")\n\t}\n\troutes := tmpl.AutoAddRoutes()\n\tif len(routes) != 3 {\n\t\tt.Fatalf(\"deepseek 应创建 messages/chat/responses 三条 route,实际 %d: %+v\", len(routes), routes)\n\t}\n\twant := map[string]struct {\n\t\tserviceType string\n\t\tbaseURL string\n\t}{\n\t\t\"messages\": {serviceType: \"claude\", baseURL: \"https://api.deepseek.com/anthropic\"},\n\t\t\"chat\": {serviceType: \"openai\", baseURL: \"https://api.deepseek.com\"},\n\t\t\"responses\": {serviceType: \"openai\", baseURL: \"https://api.deepseek.com\"},\n\t}\n\tfor _, route := range routes {\n\t\texpect, found := want[route.ChannelKind]\n\t\tif !found || route.ServiceType != expect.serviceType || len(route.Candidates) != 1 || route.Candidates[0].BaseURL != expect.baseURL {\n\t\t\tt.Fatalf(\"DeepSeek route 不符合预期: %+v\", route)\n\t\t}\n\t}\n}\n\nfunc TestProviderTemplateGLMRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"glm\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 GLM 模板\")\n\t}\n\troutes := tmpl.AutoAddRoutes()\n\tif len(routes) != 3 {\n\t\tt.Fatalf(\"GLM 应创建 messages/chat/responses 三条 route,实际 %d: %+v\", len(routes), routes)\n\t}\n\twant := map[string]struct {\n\t\tserviceType string\n\t\tbaseURL string\n\t}{\n\t\t\"messages\": {serviceType: \"claude\", baseURL: \"https://open.bigmodel.cn/api/anthropic\"},\n\t\t\"chat\": {serviceType: \"openai\", baseURL: \"https://open.bigmodel.cn/api/paas/v4#\"},\n\t\t\"responses\": {serviceType: \"openai\", baseURL: \"https://open.bigmodel.cn/api/paas/v4#\"},\n\t}\n\tfor _, route := range routes {\n\t\texpect, found := want[route.ChannelKind]\n\t\tif !found || route.ServiceType != expect.serviceType || len(route.Candidates) != 1 || route.Candidates[0].BaseURL != expect.baseURL {\n\t\t\tt.Fatalf(\"GLM route 不符合预期: %+v\", route)\n\t\t}\n\t\tif route.Candidates[0].PlanTag != \"payg\" {\n\t\t\tt.Fatalf(\"GLM route 应标记为 payg: %+v\", route.Candidates[0])\n\t\t}\n\t}\n}\n\nfunc TestInferProviderIDFromBaseURL(t *testing.T) {\n\ttests := []struct {\n\t\tbaseURL string\n\t\twant string\n\t\tok bool\n\t}{\n\t\t{baseURL: \"https://api.deepseek.com\", want: \"deepseek\", ok: true},\n\t\t{baseURL: \"https://api.deepseek.com/anthropic/v1\", want: \"deepseek\", ok: true},\n\t\t{baseURL: \"https://ark.cn-beijing.volces.com/api/plan/v3\", want: \"volcengine\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/anthropic\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/anthropic/v1/messages\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/paas/v4/\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/paas/v4/chat/completions\", want: \"glm\", ok: true},\n\t\t{baseURL: \"https://open.bigmodel.cn/api/other\", ok: false},\n\t\t{baseURL: \"https://open.bigmodel.cn.evil.example/api/paas/v4\", ok: false},\n\t\t{baseURL: \"https://relay.example/v1\", ok: false},\n\t\t{baseURL: \"https://api.deepseek.com.evil.example\", ok: false},\n\t}\n\tfor _, tt := range tests {\n\t\tgot, ok := InferProviderIDFromBaseURL(tt.baseURL)\n\t\tif got != tt.want || ok != tt.ok {\n\t\t\tt.Fatalf(\"InferProviderIDFromBaseURL(%q) = %q, %v; want %q, %v\", tt.baseURL, got, ok, tt.want, tt.ok)\n\t\t}\n\t}\n}\n\nfunc TestInferProviderIDFromAPIKey(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tapiKey string\n\t\twant string\n\t\tok bool\n\t}{\n\t\t{name: \"zhipu id.secret\", apiKey: \"0123456789abcdef0123456789abcdef.ABCDEFGHIJKLMNO1\", want: \"glm\", ok: true},\n\t\t{name: \"trim spaces\", apiKey: \" 269abc123456789012345678.r8abcdef1234 \", want: \"glm\", ok: true},\n\t\t{name: \"shared sk prefix\", apiKey: \"sk-abcdefghijklmnopqrstuvwxyz123456\", ok: false},\n\t\t{name: \"short dotted token\", apiKey: \"abc.def\", ok: false},\n\t\t{name: \"jwt\", apiKey: \"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature\", ok: false},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, ok := InferProviderIDFromAPIKey(tt.apiKey)\n\t\t\tif got != tt.want || ok != tt.ok {\n\t\t\t\tt.Fatalf(\"InferProviderIDFromAPIKey() = %q, %v; want %q, %v\", got, ok, tt.want, tt.ok)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestProviderTemplateVolcenginePlanRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"volcengine\")\n\tif !ok {\n\t\tt.Fatal(\"未找到火山方舟模板\")\n\t}\n\tif tmpl.DisplayName != \"火山方舟 Agent/Coding Plan\" || len(tmpl.AutoAddRoutes()) != 4 {\n\t\tt.Fatalf(\"火山模板不完整: %+v\", tmpl)\n\t}\n\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\tcandidates := tmpl.CandidatesForRouteKey(route, \"ark-test\")\n\t\tif len(candidates) != 2 {\n\t\t\tt.Fatalf(\"route %s 应包含 Agent/Coding 两个候选: %+v\", route.ChannelKind, candidates)\n\t\t}\n\t\tif !strings.Contains(candidates[0].BaseURL, \"/api/plan\") || !strings.Contains(candidates[1].BaseURL, \"/api/coding\") {\n\t\t\tt.Fatalf(\"route %s 候选顺序错误: %+v\", route.ChannelKind, candidates)\n\t\t}\n\t}\n}\n\nfunc TestListAndGetProviderTemplate(t *testing.T) {\n\tall := ListProviderTemplates()\n\tif len(all) < 4 {\n\t\tt.Errorf(\"内置 provider 模板应 >= 4,实际 %d\", len(all))\n\t}\n\tfor _, id := range []string{\"mimo\", \"deepseek\", \"kimi\", \"glm\", \"volcengine\"} {\n\t\tif _, ok := GetProviderTemplate(id); !ok {\n\t\t\tt.Errorf(\"缺少 provider 模板: %s\", id)\n\t\t}\n\t}\n\tif _, ok := GetProviderTemplate(\"nonexistent\"); ok {\n\t\tt.Error(\"不存在的 providerId 应返回 false\")\n\t}\n}\n\nfunc TestProviderTemplatesDoNotExposeChannelPresetRefs(t *testing.T) {\n\tdata, err := json.Marshal(ListProviderTemplates())\n\tif err != nil {\n\t\tt.Fatalf(\"序列化 provider templates 失败: %v\", err)\n\t}\n\tfor _, field := range [][]byte{[]byte(\"presetRef\"), []byte(\"presetCollection\")} {\n\t\tif bytes.Contains(data, field) {\n\t\t\tt.Fatalf(\"provider template 不应暴露 channel preset 字段 %q: %s\", field, data)\n\t\t}\n\t}\n}\n"}, "5d2ea67008b8062dd3b4f7104a16827e50c393b3": {"t": 1785759328.8228805, "n": 5852, "h": "5ba038c48fa53f73", "c": "package config\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestCandidatesForKeyPrefix(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"mimo\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 mimo 模板\")\n\t}\n\n\t// sk- 前缀 → payg 优先\n\tskCands := tmpl.CandidatesForKey(\"sk-abc\")\n\tif len(skCands) == 0 || skCands[0].PlanTag != \"payg\" {\n\t\tt.Errorf(\"sk- key 首选应为 payg,实际 %+v\", skCands)\n\t}\n\n\t// tp- 前缀 → token_plan 优先,且按 Priority(cn=0 先)\n\ttpCands := tmpl.CandidatesForKey(\"tp-xyz\")\n\tif len(tpCands) == 0 || tpCands[0].PlanTag != \"token_plan\" || tpCands[0].Region != \"cn\" {\n\t\tt.Errorf(\"tp- key 首选应为 token_plan/cn,实际 %+v\", tpCands)\n\t}\n\t// tp- 回退候选应包含 payg(全部候选都在)\n\tif len(tpCands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"tp- key 应返回全部候选(含回退)%d,实际 %d\", len(tmpl.Candidates), len(tpCands))\n\t}\n\n\t// 无匹配前缀 → 返回全部候选\n\tunknownCands := tmpl.CandidatesForKey(\"xx-none\")\n\tif len(unknownCands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"未匹配前缀应返回全部候选 %d,实际 %d\", len(tmpl.Candidates), len(unknownCands))\n\t}\n}\n\nfunc TestProviderTemplateMiMoRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"mimo\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 mimo 模板\")\n\t}\n\troutes := tmpl.AutoAddRoutes()\n\tif len(routes) != 4 {\n\t\tt.Fatalf(\"mimo 应创建 4 条 route,实际 %d: %+v\", len(routes), routes)\n\t}\n\n\twant := map[string]struct {\n\t\tserviceType string\n\t\tbaseSuffix string\n\t}{\n\t\t\"messages\": {serviceType: \"claude\", baseSuffix: \"/anthropic\"},\n\t\t\"chat\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t\t\"responses\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t\t\"gemini\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t}\n\tfor _, route := range routes {\n\t\texpect, ok := want[route.ChannelKind]\n\t\tif !ok {\n\t\t\tt.Fatalf(\"未知 route kind: %+v\", route)\n\t\t}\n\t\tif route.ServiceType != expect.serviceType {\n\t\t\tt.Fatalf(\"route %s serviceType=%s, want %s\", route.ChannelKind, route.ServiceType, expect.serviceType)\n\t\t}\n\t\tcandidates := tmpl.CandidatesForRouteKey(route, \"tp-test\")\n\t\tif len(candidates) == 0 {\n\t\t\tt.Fatalf(\"route %s 没有候选\", route.ChannelKind)\n\t\t}\n\t\tif !strings.HasSuffix(candidates[0].BaseURL, expect.baseSuffix) {\n\t\t\tt.Fatalf(\"route %s 首选 baseURL=%s, want suffix %s\", route.ChannelKind, candidates[0].BaseURL, expect.baseSuffix)\n\t\t}\n\t}\n\tif !tmpl.SupportsChannelKind(\"messages\") || !tmpl.SupportsChannelKind(\"chat\") || !tmpl.SupportsChannelKind(\"responses\") || !tmpl.SupportsChannelKind(\"gemini\") {\n\t\tt.Fatalf(\"mimo 应支持 messages/chat/responses/gemini routes: %+v\", routes)\n\t}\n}\n\nfunc TestCandidatesForKeyNoPrefixRules(t *testing.T) {\n\t// deepseek 无前缀规则,任意 key 返回全部候选\n\ttmpl, ok := GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 deepseek 模板\")\n\t}\n\tcands := tmpl.CandidatesForKey(\"anything\")\n\tif len(cands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"无前缀规则应返回全部候选 %d,实际 %d\", len(tmpl.Candidates), len(cands))\n\t}\n}\n\nfunc TestProviderTemplateDeepSeekRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 deepseek 模板\")\n\t}\n\troutes := tmpl.AutoAddRoutes()\n\tif len(routes) != 3 {\n\t\tt.Fatalf(\"deepseek 应创建 messages/chat/responses 三条 route,实际 %d: %+v\", len(routes), routes)\n\t}\n\twant := map[string]struct {\n\t\tserviceType string\n\t\tbaseURL string\n\t}{\n\t\t\"messages\": {serviceType: \"claude\", baseURL: \"https://api.deepseek.com/anthropic\"},\n\t\t\"chat\": {serviceType: \"openai\", baseURL: \"https://api.deepseek.com\"},\n\t\t\"responses\": {serviceType: \"openai\", baseURL: \"https://api.deepseek.com\"},\n\t}\n\tfor _, route := range routes {\n\t\texpect, found := want[route.ChannelKind]\n\t\tif !found || route.ServiceType != expect.serviceType || len(route.Candidates) != 1 || route.Candidates[0].BaseURL != expect.baseURL {\n\t\t\tt.Fatalf(\"DeepSeek route 不符合预期: %+v\", route)\n\t\t}\n\t}\n}\n\nfunc TestInferProviderIDFromBaseURL(t *testing.T) {\n\ttests := []struct {\n\t\tbaseURL string\n\t\twant string\n\t\tok bool\n\t}{\n\t\t{baseURL: \"https://api.deepseek.com\", want: \"deepseek\", ok: true},\n\t\t{baseURL: \"https://api.deepseek.com/anthropic/v1\", want: \"deepseek\", ok: true},\n\t\t{baseURL: \"https://ark.cn-beijing.volces.com/api/plan/v3\", want: \"volcengine\", ok: true},\n\t\t{baseURL: \"https://relay.example/v1\", ok: false},\n\t\t{baseURL: \"https://api.deepseek.com.evil.example\", ok: false},\n\t}\n\tfor _, tt := range tests {\n\t\tgot, ok := InferProviderIDFromBaseURL(tt.baseURL)\n\t\tif got != tt.want || ok != tt.ok {\n\t\t\tt.Fatalf(\"InferProviderIDFromBaseURL(%q) = %q, %v; want %q, %v\", tt.baseURL, got, ok, tt.want, tt.ok)\n\t\t}\n\t}\n}\n\nfunc TestProviderTemplateVolcenginePlanRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"volcengine\")\n\tif !ok {\n\t\tt.Fatal(\"未找到火山方舟模板\")\n\t}\n\tif tmpl.DisplayName != \"火山方舟 Agent/Coding Plan\" || len(tmpl.AutoAddRoutes()) != 4 {\n\t\tt.Fatalf(\"火山模板不完整: %+v\", tmpl)\n\t}\n\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\tcandidates := tmpl.CandidatesForRouteKey(route, \"ark-test\")\n\t\tif len(candidates) != 2 {\n\t\t\tt.Fatalf(\"route %s 应包含 Agent/Coding 两个候选: %+v\", route.ChannelKind, candidates)\n\t\t}\n\t\tif !strings.Contains(candidates[0].BaseURL, \"/api/plan\") || !strings.Contains(candidates[1].BaseURL, \"/api/coding\") {\n\t\t\tt.Fatalf(\"route %s 候选顺序错误: %+v\", route.ChannelKind, candidates)\n\t\t}\n\t}\n}\n\nfunc TestListAndGetProviderTemplate(t *testing.T) {\n\tall := ListProviderTemplates()\n\tif len(all) < 4 {\n\t\tt.Errorf(\"内置 provider 模板应 >= 4,实际 %d\", len(all))\n\t}\n\tfor _, id := range []string{\"mimo\", \"deepseek\", \"kimi\", \"glm\", \"volcengine\"} {\n\t\tif _, ok := GetProviderTemplate(id); !ok {\n\t\t\tt.Errorf(\"缺少 provider 模板: %s\", id)\n\t\t}\n\t}\n\tif _, ok := GetProviderTemplate(\"nonexistent\"); ok {\n\t\tt.Error(\"不存在的 providerId 应返回 false\")\n\t}\n}\n\nfunc TestProviderTemplatesDoNotExposeChannelPresetRefs(t *testing.T) {\n\tdata, err := json.Marshal(ListProviderTemplates())\n\tif err != nil {\n\t\tt.Fatalf(\"序列化 provider templates 失败: %v\", err)\n\t}\n\tfor _, field := range [][]byte{[]byte(\"presetRef\"), []byte(\"presetCollection\")} {\n\t\tif bytes.Contains(data, field) {\n\t\t\tt.Fatalf(\"provider template 不应暴露 channel preset 字段 %q: %s\", field, data)\n\t\t}\n\t}\n}\n"}, "6dae122a2a3fa3a18aaf07992905ade59b215860": {"t": 1785759330.4888122, "n": 4250, "h": "4844e925b50c0350", "c": "package config\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestCandidatesForKeyPrefix(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"mimo\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 mimo 模板\")\n\t}\n\n\t// sk- 前缀 → payg 优先\n\tskCands := tmpl.CandidatesForKey(\"sk-abc\")\n\tif len(skCands) == 0 || skCands[0].PlanTag != \"payg\" {\n\t\tt.Errorf(\"sk- key 首选应为 payg,实际 %+v\", skCands)\n\t}\n\n\t// tp- 前缀 → token_plan 优先,且按 Priority(cn=0 先)\n\ttpCands := tmpl.CandidatesForKey(\"tp-xyz\")\n\tif len(tpCands) == 0 || tpCands[0].PlanTag != \"token_plan\" || tpCands[0].Region != \"cn\" {\n\t\tt.Errorf(\"tp- key 首选应为 token_plan/cn,实际 %+v\", tpCands)\n\t}\n\t// tp- 回退候选应包含 payg(全部候选都在)\n\tif len(tpCands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"tp- key 应返回全部候选(含回退)%d,实际 %d\", len(tmpl.Candidates), len(tpCands))\n\t}\n\n\t// 无匹配前缀 → 返回全部候选\n\tunknownCands := tmpl.CandidatesForKey(\"xx-none\")\n\tif len(unknownCands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"未匹配前缀应返回全部候选 %d,实际 %d\", len(tmpl.Candidates), len(unknownCands))\n\t}\n}\n\nfunc TestProviderTemplateMiMoRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"mimo\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 mimo 模板\")\n\t}\n\troutes := tmpl.AutoAddRoutes()\n\tif len(routes) != 4 {\n\t\tt.Fatalf(\"mimo 应创建 4 条 route,实际 %d: %+v\", len(routes), routes)\n\t}\n\n\twant := map[string]struct {\n\t\tserviceType string\n\t\tbaseSuffix string\n\t}{\n\t\t\"messages\": {serviceType: \"claude\", baseSuffix: \"/anthropic\"},\n\t\t\"chat\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t\t\"responses\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t\t\"gemini\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t}\n\tfor _, route := range routes {\n\t\texpect, ok := want[route.ChannelKind]\n\t\tif !ok {\n\t\t\tt.Fatalf(\"未知 route kind: %+v\", route)\n\t\t}\n\t\tif route.ServiceType != expect.serviceType {\n\t\t\tt.Fatalf(\"route %s serviceType=%s, want %s\", route.ChannelKind, route.ServiceType, expect.serviceType)\n\t\t}\n\t\tcandidates := tmpl.CandidatesForRouteKey(route, \"tp-test\")\n\t\tif len(candidates) == 0 {\n\t\t\tt.Fatalf(\"route %s 没有候选\", route.ChannelKind)\n\t\t}\n\t\tif !strings.HasSuffix(candidates[0].BaseURL, expect.baseSuffix) {\n\t\t\tt.Fatalf(\"route %s 首选 baseURL=%s, want suffix %s\", route.ChannelKind, candidates[0].BaseURL, expect.baseSuffix)\n\t\t}\n\t}\n\tif !tmpl.SupportsChannelKind(\"messages\") || !tmpl.SupportsChannelKind(\"chat\") || !tmpl.SupportsChannelKind(\"responses\") || !tmpl.SupportsChannelKind(\"gemini\") {\n\t\tt.Fatalf(\"mimo 应支持 messages/chat/responses/gemini routes: %+v\", routes)\n\t}\n}\n\nfunc TestCandidatesForKeyNoPrefixRules(t *testing.T) {\n\t// deepseek 无前缀规则,任意 key 返回全部候选\n\ttmpl, ok := GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 deepseek 模板\")\n\t}\n\tcands := tmpl.CandidatesForKey(\"anything\")\n\tif len(cands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"无前缀规则应返回全部候选 %d,实际 %d\", len(tmpl.Candidates), len(cands))\n\t}\n}\n\nfunc TestProviderTemplateVolcenginePlanRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"volcengine\")\n\tif !ok {\n\t\tt.Fatal(\"未找到火山方舟模板\")\n\t}\n\tif tmpl.DisplayName != \"火山方舟 Agent/Coding Plan\" || len(tmpl.AutoAddRoutes()) != 4 {\n\t\tt.Fatalf(\"火山模板不完整: %+v\", tmpl)\n\t}\n\tfor _, route := range tmpl.AutoAddRoutes() {\n\t\tcandidates := tmpl.CandidatesForRouteKey(route, \"ark-test\")\n\t\tif len(candidates) != 2 {\n\t\t\tt.Fatalf(\"route %s 应包含 Agent/Coding 两个候选: %+v\", route.ChannelKind, candidates)\n\t\t}\n\t\tif !strings.Contains(candidates[0].BaseURL, \"/api/plan\") || !strings.Contains(candidates[1].BaseURL, \"/api/coding\") {\n\t\t\tt.Fatalf(\"route %s 候选顺序错误: %+v\", route.ChannelKind, candidates)\n\t\t}\n\t}\n}\n\nfunc TestListAndGetProviderTemplate(t *testing.T) {\n\tall := ListProviderTemplates()\n\tif len(all) < 4 {\n\t\tt.Errorf(\"内置 provider 模板应 >= 4,实际 %d\", len(all))\n\t}\n\tfor _, id := range []string{\"mimo\", \"deepseek\", \"kimi\", \"glm\", \"volcengine\"} {\n\t\tif _, ok := GetProviderTemplate(id); !ok {\n\t\t\tt.Errorf(\"缺少 provider 模板: %s\", id)\n\t\t}\n\t}\n\tif _, ok := GetProviderTemplate(\"nonexistent\"); ok {\n\t\tt.Error(\"不存在的 providerId 应返回 false\")\n\t}\n}\n\nfunc TestProviderTemplatesDoNotExposeChannelPresetRefs(t *testing.T) {\n\tdata, err := json.Marshal(ListProviderTemplates())\n\tif err != nil {\n\t\tt.Fatalf(\"序列化 provider templates 失败: %v\", err)\n\t}\n\tfor _, field := range [][]byte{[]byte(\"presetRef\"), []byte(\"presetCollection\")} {\n\t\tif bytes.Contains(data, field) {\n\t\t\tt.Fatalf(\"provider template 不应暴露 channel preset 字段 %q: %s\", field, data)\n\t\t}\n\t}\n}\n"}, "c3d5e4e76b9c81aa18fad112aebcf703968eb012": {"t": 1785759331.7847073, "n": 3551, "h": "ef0557a164080481", "c": "package config\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestCandidatesForKeyPrefix(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"mimo\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 mimo 模板\")\n\t}\n\n\t// sk- 前缀 → payg 优先\n\tskCands := tmpl.CandidatesForKey(\"sk-abc\")\n\tif len(skCands) == 0 || skCands[0].PlanTag != \"payg\" {\n\t\tt.Errorf(\"sk- key 首选应为 payg,实际 %+v\", skCands)\n\t}\n\n\t// tp- 前缀 → token_plan 优先,且按 Priority(cn=0 先)\n\ttpCands := tmpl.CandidatesForKey(\"tp-xyz\")\n\tif len(tpCands) == 0 || tpCands[0].PlanTag != \"token_plan\" || tpCands[0].Region != \"cn\" {\n\t\tt.Errorf(\"tp- key 首选应为 token_plan/cn,实际 %+v\", tpCands)\n\t}\n\t// tp- 回退候选应包含 payg(全部候选都在)\n\tif len(tpCands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"tp- key 应返回全部候选(含回退)%d,实际 %d\", len(tmpl.Candidates), len(tpCands))\n\t}\n\n\t// 无匹配前缀 → 返回全部候选\n\tunknownCands := tmpl.CandidatesForKey(\"xx-none\")\n\tif len(unknownCands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"未匹配前缀应返回全部候选 %d,实际 %d\", len(tmpl.Candidates), len(unknownCands))\n\t}\n}\n\nfunc TestProviderTemplateMiMoRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"mimo\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 mimo 模板\")\n\t}\n\troutes := tmpl.AutoAddRoutes()\n\tif len(routes) != 4 {\n\t\tt.Fatalf(\"mimo 应创建 4 条 route,实际 %d: %+v\", len(routes), routes)\n\t}\n\n\twant := map[string]struct {\n\t\tserviceType string\n\t\tbaseSuffix string\n\t}{\n\t\t\"messages\": {serviceType: \"claude\", baseSuffix: \"/anthropic\"},\n\t\t\"chat\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t\t\"responses\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t\t\"gemini\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t}\n\tfor _, route := range routes {\n\t\texpect, ok := want[route.ChannelKind]\n\t\tif !ok {\n\t\t\tt.Fatalf(\"未知 route kind: %+v\", route)\n\t\t}\n\t\tif route.ServiceType != expect.serviceType {\n\t\t\tt.Fatalf(\"route %s serviceType=%s, want %s\", route.ChannelKind, route.ServiceType, expect.serviceType)\n\t\t}\n\t\tcandidates := tmpl.CandidatesForRouteKey(route, \"tp-test\")\n\t\tif len(candidates) == 0 {\n\t\t\tt.Fatalf(\"route %s 没有候选\", route.ChannelKind)\n\t\t}\n\t\tif !strings.HasSuffix(candidates[0].BaseURL, expect.baseSuffix) {\n\t\t\tt.Fatalf(\"route %s 首选 baseURL=%s, want suffix %s\", route.ChannelKind, candidates[0].BaseURL, expect.baseSuffix)\n\t\t}\n\t}\n\tif !tmpl.SupportsChannelKind(\"messages\") || !tmpl.SupportsChannelKind(\"chat\") || !tmpl.SupportsChannelKind(\"responses\") || !tmpl.SupportsChannelKind(\"gemini\") {\n\t\tt.Fatalf(\"mimo 应支持 messages/chat/responses/gemini routes: %+v\", routes)\n\t}\n}\n\nfunc TestCandidatesForKeyNoPrefixRules(t *testing.T) {\n\t// deepseek 无前缀规则,任意 key 返回全部候选\n\ttmpl, ok := GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 deepseek 模板\")\n\t}\n\tcands := tmpl.CandidatesForKey(\"anything\")\n\tif len(cands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"无前缀规则应返回全部候选 %d,实际 %d\", len(tmpl.Candidates), len(cands))\n\t}\n}\n\nfunc TestListAndGetProviderTemplate(t *testing.T) {\n\tall := ListProviderTemplates()\n\tif len(all) < 4 {\n\t\tt.Errorf(\"内置 provider 模板应 >= 4,实际 %d\", len(all))\n\t}\n\tfor _, id := range []string{\"mimo\", \"deepseek\", \"kimi\", \"glm\"} {\n\t\tif _, ok := GetProviderTemplate(id); !ok {\n\t\t\tt.Errorf(\"缺少 provider 模板: %s\", id)\n\t\t}\n\t}\n\tif _, ok := GetProviderTemplate(\"nonexistent\"); ok {\n\t\tt.Error(\"不存在的 providerId 应返回 false\")\n\t}\n}\n\nfunc TestProviderTemplatesDoNotExposeChannelPresetRefs(t *testing.T) {\n\tdata, err := json.Marshal(ListProviderTemplates())\n\tif err != nil {\n\t\tt.Fatalf(\"序列化 provider templates 失败: %v\", err)\n\t}\n\tfor _, field := range [][]byte{[]byte(\"presetRef\"), []byte(\"presetCollection\")} {\n\t\tif bytes.Contains(data, field) {\n\t\t\tt.Fatalf(\"provider template 不应暴露 channel preset 字段 %q: %s\", field, data)\n\t\t}\n\t}\n}\n"}, "5cfccf226a320c70c7da2c8c594bc5980bba37a0": {"t": 1785759333.5889478, "n": 3446, "h": "438d73708d3a12a8", "c": "package config\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestCandidatesForKeyPrefix(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"mimo\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 mimo 模板\")\n\t}\n\n\t// sk- 前缀 → payg 优先\n\tskCands := tmpl.CandidatesForKey(\"sk-abc\")\n\tif len(skCands) == 0 || skCands[0].PlanTag != \"payg\" {\n\t\tt.Errorf(\"sk- key 首选应为 payg,实际 %+v\", skCands)\n\t}\n\n\t// tp- 前缀 → token_plan 优先,且按 Priority(cn=0 先)\n\ttpCands := tmpl.CandidatesForKey(\"tp-xyz\")\n\tif len(tpCands) == 0 || tpCands[0].PlanTag != \"token_plan\" || tpCands[0].Region != \"cn\" {\n\t\tt.Errorf(\"tp- key 首选应为 token_plan/cn,实际 %+v\", tpCands)\n\t}\n\t// tp- 回退候选应包含 payg(全部候选都在)\n\tif len(tpCands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"tp- key 应返回全部候选(含回退)%d,实际 %d\", len(tmpl.Candidates), len(tpCands))\n\t}\n\n\t// 无匹配前缀 → 返回全部候选\n\tunknownCands := tmpl.CandidatesForKey(\"xx-none\")\n\tif len(unknownCands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"未匹配前缀应返回全部候选 %d,实际 %d\", len(tmpl.Candidates), len(unknownCands))\n\t}\n}\n\nfunc TestProviderTemplateMiMoRoutes(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"mimo\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 mimo 模板\")\n\t}\n\troutes := tmpl.AutoAddRoutes()\n\tif len(routes) != 3 {\n\t\tt.Fatalf(\"mimo 应创建 3 条 route,实际 %d: %+v\", len(routes), routes)\n\t}\n\n\twant := map[string]struct {\n\t\tserviceType string\n\t\tbaseSuffix string\n\t}{\n\t\t\"messages\": {serviceType: \"claude\", baseSuffix: \"/anthropic\"},\n\t\t\"chat\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t\t\"responses\": {serviceType: \"openai\", baseSuffix: \"/v1\"},\n\t}\n\tfor _, route := range routes {\n\t\texpect, ok := want[route.ChannelKind]\n\t\tif !ok {\n\t\t\tt.Fatalf(\"未知 route kind: %+v\", route)\n\t\t}\n\t\tif route.ServiceType != expect.serviceType {\n\t\t\tt.Fatalf(\"route %s serviceType=%s, want %s\", route.ChannelKind, route.ServiceType, expect.serviceType)\n\t\t}\n\t\tcandidates := tmpl.CandidatesForRouteKey(route, \"tp-test\")\n\t\tif len(candidates) == 0 {\n\t\t\tt.Fatalf(\"route %s 没有候选\", route.ChannelKind)\n\t\t}\n\t\tif !strings.HasSuffix(candidates[0].BaseURL, expect.baseSuffix) {\n\t\t\tt.Fatalf(\"route %s 首选 baseURL=%s, want suffix %s\", route.ChannelKind, candidates[0].BaseURL, expect.baseSuffix)\n\t\t}\n\t}\n\tif !tmpl.SupportsChannelKind(\"messages\") || !tmpl.SupportsChannelKind(\"chat\") || !tmpl.SupportsChannelKind(\"responses\") {\n\t\tt.Fatalf(\"mimo 应支持 messages/chat/responses routes: %+v\", routes)\n\t}\n}\n\nfunc TestCandidatesForKeyNoPrefixRules(t *testing.T) {\n\t// deepseek 无前缀规则,任意 key 返回全部候选\n\ttmpl, ok := GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 deepseek 模板\")\n\t}\n\tcands := tmpl.CandidatesForKey(\"anything\")\n\tif len(cands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"无前缀规则应返回全部候选 %d,实际 %d\", len(tmpl.Candidates), len(cands))\n\t}\n}\n\nfunc TestListAndGetProviderTemplate(t *testing.T) {\n\tall := ListProviderTemplates()\n\tif len(all) < 4 {\n\t\tt.Errorf(\"内置 provider 模板应 >= 4,实际 %d\", len(all))\n\t}\n\tfor _, id := range []string{\"mimo\", \"deepseek\", \"kimi\", \"glm\"} {\n\t\tif _, ok := GetProviderTemplate(id); !ok {\n\t\t\tt.Errorf(\"缺少 provider 模板: %s\", id)\n\t\t}\n\t}\n\tif _, ok := GetProviderTemplate(\"nonexistent\"); ok {\n\t\tt.Error(\"不存在的 providerId 应返回 false\")\n\t}\n}\n\nfunc TestProviderTemplatesDoNotExposeChannelPresetRefs(t *testing.T) {\n\tdata, err := json.Marshal(ListProviderTemplates())\n\tif err != nil {\n\t\tt.Fatalf(\"序列化 provider templates 失败: %v\", err)\n\t}\n\tfor _, field := range [][]byte{[]byte(\"presetRef\"), []byte(\"presetCollection\")} {\n\t\tif bytes.Contains(data, field) {\n\t\t\tt.Fatalf(\"provider template 不应暴露 channel preset 字段 %q: %s\", field, data)\n\t\t}\n\t}\n}\n"}, "ffa88710c25307f08590908fc0c707ec80d6ab21": {"t": 1785759335.2479687, "n": 2106, "h": "50d100cece7858ff", "c": "package config\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"testing\"\n)\n\nfunc TestCandidatesForKeyPrefix(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"mimo\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 mimo 模板\")\n\t}\n\n\t// sk- 前缀 → payg 优先\n\tskCands := tmpl.CandidatesForKey(\"sk-abc\")\n\tif len(skCands) == 0 || skCands[0].PlanTag != \"payg\" {\n\t\tt.Errorf(\"sk- key 首选应为 payg,实际 %+v\", skCands)\n\t}\n\n\t// tp- 前缀 → token_plan 优先,且按 Priority(cn=0 先)\n\ttpCands := tmpl.CandidatesForKey(\"tp-xyz\")\n\tif len(tpCands) == 0 || tpCands[0].PlanTag != \"token_plan\" || tpCands[0].Region != \"cn\" {\n\t\tt.Errorf(\"tp- key 首选应为 token_plan/cn,实际 %+v\", tpCands)\n\t}\n\t// tp- 回退候选应包含 payg(全部候选都在)\n\tif len(tpCands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"tp- key 应返回全部候选(含回退)%d,实际 %d\", len(tmpl.Candidates), len(tpCands))\n\t}\n\n\t// 无匹配前缀 → 返回全部候选\n\tunknownCands := tmpl.CandidatesForKey(\"xx-none\")\n\tif len(unknownCands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"未匹配前缀应返回全部候选 %d,实际 %d\", len(tmpl.Candidates), len(unknownCands))\n\t}\n}\n\nfunc TestCandidatesForKeyNoPrefixRules(t *testing.T) {\n\t// deepseek 无前缀规则,任意 key 返回全部候选\n\ttmpl, ok := GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 deepseek 模板\")\n\t}\n\tcands := tmpl.CandidatesForKey(\"anything\")\n\tif len(cands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"无前缀规则应返回全部候选 %d,实际 %d\", len(tmpl.Candidates), len(cands))\n\t}\n}\n\nfunc TestListAndGetProviderTemplate(t *testing.T) {\n\tall := ListProviderTemplates()\n\tif len(all) < 4 {\n\t\tt.Errorf(\"内置 provider 模板应 >= 4,实际 %d\", len(all))\n\t}\n\tfor _, id := range []string{\"mimo\", \"deepseek\", \"kimi\", \"glm\"} {\n\t\tif _, ok := GetProviderTemplate(id); !ok {\n\t\t\tt.Errorf(\"缺少 provider 模板: %s\", id)\n\t\t}\n\t}\n\tif _, ok := GetProviderTemplate(\"nonexistent\"); ok {\n\t\tt.Error(\"不存在的 providerId 应返回 false\")\n\t}\n}\n\nfunc TestProviderTemplatesDoNotExposeChannelPresetRefs(t *testing.T) {\n\tdata, err := json.Marshal(ListProviderTemplates())\n\tif err != nil {\n\t\tt.Fatalf(\"序列化 provider templates 失败: %v\", err)\n\t}\n\tfor _, field := range [][]byte{[]byte(\"presetRef\"), []byte(\"presetCollection\")} {\n\t\tif bytes.Contains(data, field) {\n\t\t\tt.Fatalf(\"provider template 不应暴露 channel preset 字段 %q: %s\", field, data)\n\t\t}\n\t}\n}\n"}, "9f641ade6dfc313ec869e0911d11b5b499db8bcc": {"t": 1785759336.9420478, "n": 1679, "h": "3268ab53709d78e7", "c": "package config\n\nimport \"testing\"\n\nfunc TestCandidatesForKeyPrefix(t *testing.T) {\n\ttmpl, ok := GetProviderTemplate(\"mimo\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 mimo 模板\")\n\t}\n\n\t// sk- 前缀 → payg 优先\n\tskCands := tmpl.CandidatesForKey(\"sk-abc\")\n\tif len(skCands) == 0 || skCands[0].PlanTag != \"payg\" {\n\t\tt.Errorf(\"sk- key 首选应为 payg,实际 %+v\", skCands)\n\t}\n\n\t// tp- 前缀 → token_plan 优先,且按 Priority(cn=0 先)\n\ttpCands := tmpl.CandidatesForKey(\"tp-xyz\")\n\tif len(tpCands) == 0 || tpCands[0].PlanTag != \"token_plan\" || tpCands[0].Region != \"cn\" {\n\t\tt.Errorf(\"tp- key 首选应为 token_plan/cn,实际 %+v\", tpCands)\n\t}\n\t// tp- 回退候选应包含 payg(全部候选都在)\n\tif len(tpCands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"tp- key 应返回全部候选(含回退)%d,实际 %d\", len(tmpl.Candidates), len(tpCands))\n\t}\n\n\t// 无匹配前缀 → 返回全部候选\n\tunknownCands := tmpl.CandidatesForKey(\"xx-none\")\n\tif len(unknownCands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"未匹配前缀应返回全部候选 %d,实际 %d\", len(tmpl.Candidates), len(unknownCands))\n\t}\n}\n\nfunc TestCandidatesForKeyNoPrefixRules(t *testing.T) {\n\t// deepseek 无前缀规则,任意 key 返回全部候选\n\ttmpl, ok := GetProviderTemplate(\"deepseek\")\n\tif !ok {\n\t\tt.Fatal(\"未找到 deepseek 模板\")\n\t}\n\tcands := tmpl.CandidatesForKey(\"anything\")\n\tif len(cands) != len(tmpl.Candidates) {\n\t\tt.Errorf(\"无前缀规则应返回全部候选 %d,实际 %d\", len(tmpl.Candidates), len(cands))\n\t}\n}\n\nfunc TestListAndGetProviderTemplate(t *testing.T) {\n\tall := ListProviderTemplates()\n\tif len(all) < 4 {\n\t\tt.Errorf(\"内置 provider 模板应 >= 4,实际 %d\", len(all))\n\t}\n\tfor _, id := range []string{\"mimo\", \"deepseek\", \"kimi\", \"glm\"} {\n\t\tif _, ok := GetProviderTemplate(id); !ok {\n\t\t\tt.Errorf(\"缺少 provider 模板: %s\", id)\n\t\t}\n\t}\n\tif _, ok := GetProviderTemplate(\"nonexistent\"); ok {\n\t\tt.Error(\"不存在的 providerId 应返回 false\")\n\t}\n}\n"}}, "docs/public/presets/builtin-manifest.json": {"48488333f5455eef9b0999cee1eaf5bb63dd23d6": {"t": 1785746298.0612762, "n": 6453, "h": "87a56da9355c15cf", "c": "{\n \"schemaVersion\": 1,\n \"manifests\": [\n {\n \"baseUrlPattern\": \"api.anthropic.com\",\n \"serviceType\": \"messages\",\n \"planHint\": \"anthropic_api\",\n \"modelIds\": [\n \"claude-fable-5\",\n \"claude-mythos-5\",\n \"claude-opus-4-8\",\n \"claude-opus-4-7\",\n \"claude-opus-4-6\",\n \"claude-sonnet-5\",\n \"claude-sonnet-4-6\",\n \"claude-sonnet-4-5\",\n \"claude-opus-4-5\",\n \"claude-haiku-4-5\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"api.deepseek.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"deepseek_anthropic\",\n \"modelsUrl\": \"https://api.deepseek.com/models\",\n \"modelIds\": [\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"api.deepseek.com\",\n \"serviceType\": \"openai\",\n \"planHint\": \"deepseek_openai\",\n \"modelsUrl\": \"https://api.deepseek.com/models\",\n \"modelIds\": [\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"api.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_payg_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-cn.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_cn_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-sgp.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_sgp_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-ams.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_ams_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"api.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_payg_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"token-plan-cn.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_token_plan_cn_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"token-plan-sgp.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_token_plan_sgp_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"token-plan-ams.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_token_plan_ams_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"api.kimi.com/coding\",\n \"serviceType\": \"messages\",\n \"planHint\": \"kimi_code_anthropic\",\n \"modelsUrl\": \"https://api.kimi.com/coding/v1/models\",\n \"modelIds\": [\n \"kimi-for-coding\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"api.kimi.com/coding/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"kimi_code_openai\",\n \"modelsUrl\": \"https://api.kimi.com/coding/v1/models\",\n \"modelIds\": [\n \"kimi-for-coding\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"ark.cn-beijing.volces.com/api/plan\",\n \"serviceType\": \"messages\",\n \"planHint\": \"volcengine_plan_anthropic\",\n \"modelIds\": [\n \"doubao-seed-2.0-code\",\n \"doubao-seed-2.0-pro\",\n \"doubao-seed-2.0-lite\",\n \"doubao-seed-2.0-mini\",\n \"minimax-m2.7\",\n \"minimax-m3\",\n \"glm-5.2\",\n \"glm-latest\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-pro\",\n \"kimi-k3\",\n \"kimi-k2.6\",\n \"kimi-k2.7-code\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"ark.cn-beijing.volces.com/api/coding\",\n \"serviceType\": \"messages\",\n \"planHint\": \"volcengine_coding_anthropic\",\n \"modelIds\": [\n \"doubao-seed-2.0-code\",\n \"doubao-seed-2.0-pro\",\n \"doubao-seed-2.0-lite\",\n \"doubao-seed-code\",\n \"minimax-m2.7\",\n \"minimax-m3\",\n \"glm-5.2\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-pro\",\n \"kimi-k2.6\",\n \"kimi-k2.7-code\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"ark.cn-beijing.volces.com/api/plan\",\n \"serviceType\": \"openai\",\n \"planHint\": \"volcengine_plan_openai\",\n \"modelIds\": [\n \"doubao-seed-2.0-code\",\n \"doubao-seed-2.0-pro\",\n \"doubao-seed-2.0-lite\",\n \"doubao-seed-2.0-mini\",\n \"minimax-m2.7\",\n \"minimax-m3\",\n \"glm-5.2\",\n \"glm-latest\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-pro\",\n \"kimi-k3\",\n \"kimi-k2.6\",\n \"kimi-k2.7-code\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"ark.cn-beijing.volces.com/api/coding\",\n \"serviceType\": \"openai\",\n \"planHint\": \"volcengine_coding_openai\",\n \"modelIds\": [\n \"doubao-seed-2.0-code\",\n \"doubao-seed-2.0-pro\",\n \"doubao-seed-2.0-lite\",\n \"doubao-seed-code\",\n \"minimax-m2.7\",\n \"minimax-m3\",\n \"glm-5.2\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-pro\",\n \"kimi-k2.6\",\n \"kimi-k2.7-code\"\n ],\n \"disableProbe\": true\n }\n ]\n}\n"}, "713d7e08c5426d026f638467afee4828d12a4481": {"t": 1785759314.9928799, "n": 6453, "h": "87a56da9355c15cf", "c": "{\n \"schemaVersion\": 1,\n \"manifests\": [\n {\n \"baseUrlPattern\": \"api.anthropic.com\",\n \"serviceType\": \"messages\",\n \"planHint\": \"anthropic_api\",\n \"modelIds\": [\n \"claude-fable-5\",\n \"claude-mythos-5\",\n \"claude-opus-4-8\",\n \"claude-opus-4-7\",\n \"claude-opus-4-6\",\n \"claude-sonnet-5\",\n \"claude-sonnet-4-6\",\n \"claude-sonnet-4-5\",\n \"claude-opus-4-5\",\n \"claude-haiku-4-5\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"api.deepseek.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"deepseek_anthropic\",\n \"modelsUrl\": \"https://api.deepseek.com/models\",\n \"modelIds\": [\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"api.deepseek.com\",\n \"serviceType\": \"openai\",\n \"planHint\": \"deepseek_openai\",\n \"modelsUrl\": \"https://api.deepseek.com/models\",\n \"modelIds\": [\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"api.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_payg_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-cn.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_cn_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-sgp.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_sgp_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-ams.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_ams_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"api.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_payg_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"token-plan-cn.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_token_plan_cn_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"token-plan-sgp.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_token_plan_sgp_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"token-plan-ams.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_token_plan_ams_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"api.kimi.com/coding\",\n \"serviceType\": \"messages\",\n \"planHint\": \"kimi_code_anthropic\",\n \"modelsUrl\": \"https://api.kimi.com/coding/v1/models\",\n \"modelIds\": [\n \"kimi-for-coding\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"api.kimi.com/coding/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"kimi_code_openai\",\n \"modelsUrl\": \"https://api.kimi.com/coding/v1/models\",\n \"modelIds\": [\n \"kimi-for-coding\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"ark.cn-beijing.volces.com/api/plan\",\n \"serviceType\": \"messages\",\n \"planHint\": \"volcengine_plan_anthropic\",\n \"modelIds\": [\n \"doubao-seed-2.0-code\",\n \"doubao-seed-2.0-pro\",\n \"doubao-seed-2.0-lite\",\n \"doubao-seed-2.0-mini\",\n \"minimax-m2.7\",\n \"minimax-m3\",\n \"glm-5.2\",\n \"glm-latest\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-pro\",\n \"kimi-k3\",\n \"kimi-k2.6\",\n \"kimi-k2.7-code\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"ark.cn-beijing.volces.com/api/coding\",\n \"serviceType\": \"messages\",\n \"planHint\": \"volcengine_coding_anthropic\",\n \"modelIds\": [\n \"doubao-seed-2.0-code\",\n \"doubao-seed-2.0-pro\",\n \"doubao-seed-2.0-lite\",\n \"doubao-seed-code\",\n \"minimax-m2.7\",\n \"minimax-m3\",\n \"glm-5.2\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-pro\",\n \"kimi-k2.6\",\n \"kimi-k2.7-code\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"ark.cn-beijing.volces.com/api/plan\",\n \"serviceType\": \"openai\",\n \"planHint\": \"volcengine_plan_openai\",\n \"modelIds\": [\n \"doubao-seed-2.0-code\",\n \"doubao-seed-2.0-pro\",\n \"doubao-seed-2.0-lite\",\n \"doubao-seed-2.0-mini\",\n \"minimax-m2.7\",\n \"minimax-m3\",\n \"glm-5.2\",\n \"glm-latest\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-pro\",\n \"kimi-k3\",\n \"kimi-k2.6\",\n \"kimi-k2.7-code\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"ark.cn-beijing.volces.com/api/coding\",\n \"serviceType\": \"openai\",\n \"planHint\": \"volcengine_coding_openai\",\n \"modelIds\": [\n \"doubao-seed-2.0-code\",\n \"doubao-seed-2.0-pro\",\n \"doubao-seed-2.0-lite\",\n \"doubao-seed-code\",\n \"minimax-m2.7\",\n \"minimax-m3\",\n \"glm-5.2\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-pro\",\n \"kimi-k2.6\",\n \"kimi-k2.7-code\"\n ],\n \"disableProbe\": true\n }\n ]\n}\n"}, "be325312222deca30ed2abbef56312bca2275479": {"t": 1785759316.2837873, "n": 6415, "h": "b9363255b84fbb63", "c": "{\n \"schemaVersion\": 1,\n \"manifests\": [\n {\n \"baseUrlPattern\": \"api.anthropic.com\",\n \"serviceType\": \"messages\",\n \"planHint\": \"anthropic_api\",\n \"modelIds\": [\n \"claude-fable-5\",\n \"claude-mythos-5\",\n \"claude-opus-4-8\",\n \"claude-opus-4-7\",\n \"claude-opus-4-6\",\n \"claude-sonnet-5\",\n \"claude-sonnet-4-6\",\n \"claude-sonnet-4-5\",\n \"claude-opus-4-5\",\n \"claude-haiku-4-5\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"api.deepseek.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"deepseek_anthropic\",\n \"modelsUrl\": \"https://api.deepseek.com/models\",\n \"modelIds\": [\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"api.deepseek.com\",\n \"serviceType\": \"openai\",\n \"planHint\": \"deepseek_openai\",\n \"modelsUrl\": \"https://api.deepseek.com/models\",\n \"modelIds\": [\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"api.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_payg_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-cn.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_cn_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-sgp.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_sgp_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-ams.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_ams_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"api.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_payg_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"token-plan-cn.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_token_plan_cn_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"token-plan-sgp.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_token_plan_sgp_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"token-plan-ams.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_token_plan_ams_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"api.kimi.com/coding\",\n \"serviceType\": \"messages\",\n \"planHint\": \"kimi_code_anthropic\",\n \"modelsUrl\": \"https://api.kimi.com/coding/v1/models\",\n \"modelIds\": [\n \"kimi-for-coding\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"api.kimi.com/coding/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"kimi_code_openai\",\n \"modelsUrl\": \"https://api.kimi.com/coding/v1/models\",\n \"modelIds\": [\n \"kimi-for-coding\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"ark.cn-beijing.volces.com/api/plan\",\n \"serviceType\": \"messages\",\n \"planHint\": \"volcengine_plan_anthropic\",\n \"modelIds\": [\n \"doubao-seed-2.0-code\",\n \"doubao-seed-2.0-pro\",\n \"doubao-seed-2.0-lite\",\n \"doubao-seed-2.0-mini\",\n \"minimax-m2.7\",\n \"minimax-m3\",\n \"glm-5.2\",\n \"glm-latest\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-pro\",\n \"kimi-k2.6\",\n \"kimi-k2.7-code\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"ark.cn-beijing.volces.com/api/coding\",\n \"serviceType\": \"messages\",\n \"planHint\": \"volcengine_coding_anthropic\",\n \"modelIds\": [\n \"doubao-seed-2.0-code\",\n \"doubao-seed-2.0-pro\",\n \"doubao-seed-2.0-lite\",\n \"doubao-seed-code\",\n \"minimax-m2.7\",\n \"minimax-m3\",\n \"glm-5.2\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-pro\",\n \"kimi-k2.6\",\n \"kimi-k2.7-code\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"ark.cn-beijing.volces.com/api/plan\",\n \"serviceType\": \"openai\",\n \"planHint\": \"volcengine_plan_openai\",\n \"modelIds\": [\n \"doubao-seed-2.0-code\",\n \"doubao-seed-2.0-pro\",\n \"doubao-seed-2.0-lite\",\n \"doubao-seed-2.0-mini\",\n \"minimax-m2.7\",\n \"minimax-m3\",\n \"glm-5.2\",\n \"glm-latest\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-pro\",\n \"kimi-k2.6\",\n \"kimi-k2.7-code\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"ark.cn-beijing.volces.com/api/coding\",\n \"serviceType\": \"openai\",\n \"planHint\": \"volcengine_coding_openai\",\n \"modelIds\": [\n \"doubao-seed-2.0-code\",\n \"doubao-seed-2.0-pro\",\n \"doubao-seed-2.0-lite\",\n \"doubao-seed-code\",\n \"minimax-m2.7\",\n \"minimax-m3\",\n \"glm-5.2\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-pro\",\n \"kimi-k2.6\",\n \"kimi-k2.7-code\"\n ],\n \"disableProbe\": true\n }\n ]\n}\n"}, "5d2ea67008b8062dd3b4f7104a16827e50c393b3": {"t": 1785759317.5920212, "n": 5863, "h": "0056f827416d2295", "c": "{\n \"schemaVersion\": 1,\n \"manifests\": [\n {\n \"baseUrlPattern\": \"api.anthropic.com\",\n \"serviceType\": \"messages\",\n \"planHint\": \"anthropic_api\",\n \"modelIds\": [\n \"claude-fable-5\",\n \"claude-mythos-5\",\n \"claude-opus-4-8\",\n \"claude-opus-4-7\",\n \"claude-opus-4-6\",\n \"claude-sonnet-5\",\n \"claude-sonnet-4-6\",\n \"claude-sonnet-4-5\",\n \"claude-opus-4-5\",\n \"claude-haiku-4-5\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"api.deepseek.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"deepseek_anthropic\",\n \"modelsUrl\": \"https://api.deepseek.com/models\",\n \"modelIds\": [\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"api.deepseek.com\",\n \"serviceType\": \"openai\",\n \"planHint\": \"deepseek_openai\",\n \"modelsUrl\": \"https://api.deepseek.com/models\",\n \"modelIds\": [\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"api.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_payg_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-cn.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_cn_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-sgp.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_sgp_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-ams.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_ams_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"api.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_payg_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"token-plan-cn.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_token_plan_cn_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"token-plan-sgp.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_token_plan_sgp_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"token-plan-ams.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_token_plan_ams_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"ark.cn-beijing.volces.com/api/plan\",\n \"serviceType\": \"messages\",\n \"planHint\": \"volcengine_plan_anthropic\",\n \"modelIds\": [\n \"doubao-seed-2.0-code\",\n \"doubao-seed-2.0-pro\",\n \"doubao-seed-2.0-lite\",\n \"doubao-seed-2.0-mini\",\n \"minimax-m2.7\",\n \"minimax-m3\",\n \"glm-5.2\",\n \"glm-latest\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-pro\",\n \"kimi-k2.6\",\n \"kimi-k2.7-code\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"ark.cn-beijing.volces.com/api/coding\",\n \"serviceType\": \"messages\",\n \"planHint\": \"volcengine_coding_anthropic\",\n \"modelIds\": [\n \"doubao-seed-2.0-code\",\n \"doubao-seed-2.0-pro\",\n \"doubao-seed-2.0-lite\",\n \"doubao-seed-code\",\n \"minimax-m2.7\",\n \"minimax-m3\",\n \"glm-5.2\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-pro\",\n \"kimi-k2.6\",\n \"kimi-k2.7-code\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"ark.cn-beijing.volces.com/api/plan\",\n \"serviceType\": \"openai\",\n \"planHint\": \"volcengine_plan_openai\",\n \"modelIds\": [\n \"doubao-seed-2.0-code\",\n \"doubao-seed-2.0-pro\",\n \"doubao-seed-2.0-lite\",\n \"doubao-seed-2.0-mini\",\n \"minimax-m2.7\",\n \"minimax-m3\",\n \"glm-5.2\",\n \"glm-latest\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-pro\",\n \"kimi-k2.6\",\n \"kimi-k2.7-code\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"ark.cn-beijing.volces.com/api/coding\",\n \"serviceType\": \"openai\",\n \"planHint\": \"volcengine_coding_openai\",\n \"modelIds\": [\n \"doubao-seed-2.0-code\",\n \"doubao-seed-2.0-pro\",\n \"doubao-seed-2.0-lite\",\n \"doubao-seed-code\",\n \"minimax-m2.7\",\n \"minimax-m3\",\n \"glm-5.2\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-pro\",\n \"kimi-k2.6\",\n \"kimi-k2.7-code\"\n ],\n \"disableProbe\": true\n }\n ]\n}\n"}, "788018ad7a01699347a7a3852bacd2be1bcd0347": {"t": 1785759319.2022965, "n": 5266, "h": "535e36bf8aff9e4d", "c": "{\n \"schemaVersion\": 1,\n \"manifests\": [\n {\n \"baseUrlPattern\": \"api.anthropic.com\",\n \"serviceType\": \"messages\",\n \"planHint\": \"anthropic_api\",\n \"modelIds\": [\n \"claude-fable-5\",\n \"claude-mythos-5\",\n \"claude-opus-4-8\",\n \"claude-opus-4-7\",\n \"claude-opus-4-6\",\n \"claude-sonnet-5\",\n \"claude-sonnet-4-6\",\n \"claude-sonnet-4-5\",\n \"claude-opus-4-5\",\n \"claude-haiku-4-5\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"api.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_payg_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-cn.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_cn_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-sgp.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_sgp_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-ams.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_ams_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"api.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_payg_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"token-plan-cn.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_token_plan_cn_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"token-plan-sgp.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_token_plan_sgp_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"token-plan-ams.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_token_plan_ams_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"ark.cn-beijing.volces.com/api/plan\",\n \"serviceType\": \"messages\",\n \"planHint\": \"volcengine_plan_anthropic\",\n \"modelIds\": [\n \"doubao-seed-2.0-code\",\n \"doubao-seed-2.0-pro\",\n \"doubao-seed-2.0-lite\",\n \"doubao-seed-2.0-mini\",\n \"minimax-m2.7\",\n \"minimax-m3\",\n \"glm-5.2\",\n \"glm-latest\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-pro\",\n \"kimi-k2.6\",\n \"kimi-k2.7-code\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"ark.cn-beijing.volces.com/api/coding\",\n \"serviceType\": \"messages\",\n \"planHint\": \"volcengine_coding_anthropic\",\n \"modelIds\": [\n \"doubao-seed-2.0-code\",\n \"doubao-seed-2.0-pro\",\n \"doubao-seed-2.0-lite\",\n \"doubao-seed-code\",\n \"minimax-m2.7\",\n \"minimax-m3\",\n \"glm-5.2\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-pro\",\n \"kimi-k2.6\",\n \"kimi-k2.7-code\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"ark.cn-beijing.volces.com/api/plan\",\n \"serviceType\": \"openai\",\n \"planHint\": \"volcengine_plan_openai\",\n \"modelIds\": [\n \"doubao-seed-2.0-code\",\n \"doubao-seed-2.0-pro\",\n \"doubao-seed-2.0-lite\",\n \"doubao-seed-2.0-mini\",\n \"minimax-m2.7\",\n \"minimax-m3\",\n \"glm-5.2\",\n \"glm-latest\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-pro\",\n \"kimi-k2.6\",\n \"kimi-k2.7-code\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"ark.cn-beijing.volces.com/api/coding\",\n \"serviceType\": \"openai\",\n \"planHint\": \"volcengine_coding_openai\",\n \"modelIds\": [\n \"doubao-seed-2.0-code\",\n \"doubao-seed-2.0-pro\",\n \"doubao-seed-2.0-lite\",\n \"doubao-seed-code\",\n \"minimax-m2.7\",\n \"minimax-m3\",\n \"glm-5.2\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-pro\",\n \"kimi-k2.6\",\n \"kimi-k2.7-code\"\n ],\n \"disableProbe\": true\n }\n ]\n}\n"}, "2e6ca48e0a7beaff0dacf80672147c7e9e0db22f": {"t": 1785759320.4935217, "n": 5302, "h": "96956c69a7623de4", "c": "{\n \"schemaVersion\": 1,\n \"manifests\": [\n {\n \"baseUrlPattern\": \"api.anthropic.com\",\n \"serviceType\": \"messages\",\n \"planHint\": \"anthropic_api\",\n \"modelIds\": [\n \"claude-fable-5\",\n \"claude-mythos-5\",\n \"claude-opus-4-8\",\n \"claude-opus-4-7\",\n \"claude-opus-4-6\",\n \"claude-sonnet-5\",\n \"claude-sonnet-4-6\",\n \"claude-sonnet-4-5\",\n \"claude-opus-4-5\",\n \"claude-haiku-4-5\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"api.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_payg_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-cn.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_cn_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-sgp.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_sgp_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-ams.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_ams_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"api.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_payg_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"token-plan-cn.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_token_plan_cn_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"token-plan-sgp.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_token_plan_sgp_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"token-plan-ams.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_token_plan_ams_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"ark.cn-beijing.volces.com/api/plan\",\n \"serviceType\": \"messages\",\n \"planHint\": \"volcengine_plan_anthropic\",\n \"modelIds\": [\n \"doubao-seed-2.0-code\",\n \"doubao-seed-2.0-pro\",\n \"doubao-seed-2.0-lite\",\n \"doubao-seed-code\",\n \"minimax-m2.7\",\n \"minimax-m3\",\n \"glm-5.2\",\n \"glm-latest\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-pro\",\n \"kimi-k2.6\",\n \"kimi-k2.7-code\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"ark.cn-beijing.volces.com/api/coding\",\n \"serviceType\": \"messages\",\n \"planHint\": \"volcengine_coding_anthropic\",\n \"modelIds\": [\n \"doubao-seed-2.0-code\",\n \"doubao-seed-2.0-pro\",\n \"doubao-seed-2.0-lite\",\n \"doubao-seed-code\",\n \"minimax-m2.7\",\n \"minimax-m3\",\n \"glm-5.2\",\n \"glm-latest\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-pro\",\n \"kimi-k2.6\",\n \"kimi-k2.7-code\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"ark.cn-beijing.volces.com/api/plan\",\n \"serviceType\": \"openai\",\n \"planHint\": \"volcengine_plan_openai\",\n \"modelIds\": [\n \"doubao-seed-2.0-code\",\n \"doubao-seed-2.0-pro\",\n \"doubao-seed-2.0-lite\",\n \"doubao-seed-code\",\n \"minimax-m2.7\",\n \"minimax-m3\",\n \"glm-5.2\",\n \"glm-latest\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-pro\",\n \"kimi-k2.6\",\n \"kimi-k2.7-code\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"ark.cn-beijing.volces.com/api/coding\",\n \"serviceType\": \"openai\",\n \"planHint\": \"volcengine_coding_openai\",\n \"modelIds\": [\n \"doubao-seed-2.0-code\",\n \"doubao-seed-2.0-pro\",\n \"doubao-seed-2.0-lite\",\n \"doubao-seed-code\",\n \"minimax-m2.7\",\n \"minimax-m3\",\n \"glm-5.2\",\n \"glm-latest\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-pro\",\n \"kimi-k2.6\",\n \"kimi-k2.7-code\"\n ],\n \"disableProbe\": true\n }\n ]\n}\n"}, "5cfccf226a320c70c7da2c8c594bc5980bba37a0": {"t": 1785759321.7553172, "n": 3212, "h": "22157f9b4e5338cc", "c": "{\n \"schemaVersion\": 1,\n \"manifests\": [\n {\n \"baseUrlPattern\": \"api.anthropic.com\",\n \"serviceType\": \"messages\",\n \"planHint\": \"anthropic_api\",\n \"modelIds\": [\n \"claude-fable-5\",\n \"claude-mythos-5\",\n \"claude-opus-4-8\",\n \"claude-opus-4-7\",\n \"claude-opus-4-6\",\n \"claude-sonnet-5\",\n \"claude-sonnet-4-6\",\n \"claude-sonnet-4-5\",\n \"claude-opus-4-5\",\n \"claude-haiku-4-5\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"api.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_payg_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-cn.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_cn_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-sgp.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_sgp_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-ams.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_ams_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"api.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_payg_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"token-plan-cn.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_token_plan_cn_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"token-plan-sgp.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_token_plan_sgp_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"token-plan-ams.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_token_plan_ams_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n }\n ]\n}\n"}, "ade2bae08c3ac61539293f45ee34f7fa1b06b4db": {"t": 1785759323.0639503, "n": 1879, "h": "cc79ee01f4fb396a", "c": "{\n \"schemaVersion\": 1,\n \"manifests\": [\n {\n \"baseUrlPattern\": \"api.anthropic.com\",\n \"serviceType\": \"messages\",\n \"planHint\": \"anthropic_api\",\n \"modelIds\": [\n \"claude-fable-5\",\n \"claude-mythos-5\",\n \"claude-opus-4-8\",\n \"claude-opus-4-7\",\n \"claude-opus-4-6\",\n \"claude-sonnet-5\",\n \"claude-sonnet-4-6\",\n \"claude-sonnet-4-5\",\n \"claude-opus-4-5\",\n \"claude-haiku-4-5\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"api.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_payg_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-cn.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_cn_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-sgp.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_sgp_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-ams.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_ams_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n }\n ]\n}\n"}, "238c29d42be793fa97f4f469e72736877f07e24e": {"t": 1785759325.534443, "n": 1639, "h": "8f25cabc1176efd6", "c": "{\n \"schemaVersion\": 1,\n \"manifests\": [\n {\n \"baseUrlPattern\": \"api.anthropic.com\",\n \"serviceType\": \"messages\",\n \"planHint\": \"anthropic_api\",\n \"modelIds\": [\n \"claude-fable-5\",\n \"claude-mythos-5\",\n \"claude-opus-4-8\",\n \"claude-opus-4-7\",\n \"claude-opus-4-6\",\n \"claude-sonnet-5\",\n \"claude-sonnet-4-6\",\n \"claude-sonnet-4-5\",\n \"claude-opus-4-5\",\n \"claude-haiku-4-5\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"api.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_payg_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\",\n \"mimo-v2-flash\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-cn.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_cn_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\",\n \"mimo-v2-flash\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-sgp.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_sgp_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\",\n \"mimo-v2-flash\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-ams.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_ams_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\",\n \"mimo-v2-flash\"\n ],\n \"disableProbe\": true\n }\n ]\n}\n"}, "c046d05c969b90a526b7c12638c0731e88b51e30": {"t": 1785759327.4282577, "n": 502, "h": "c19de5b1c2cf6dec", "c": "{\n \"schemaVersion\": 1,\n \"manifests\": [\n {\n \"baseUrlPattern\": \"api.anthropic.com\",\n \"serviceType\": \"messages\",\n \"planHint\": \"anthropic_api\",\n \"modelIds\": [\n \"claude-fable-5\",\n \"claude-mythos-5\",\n \"claude-opus-4-8\",\n \"claude-opus-4-7\",\n \"claude-opus-4-6\",\n \"claude-sonnet-5\",\n \"claude-sonnet-4-6\",\n \"claude-sonnet-4-5\",\n \"claude-opus-4-5\",\n \"claude-haiku-4-5\"\n ],\n \"disableProbe\": false\n }\n ]\n}\n"}, "71b842e3e8300d0b9329446af03a39c498580b55": {"t": 1786067680.747969, "n": 6453, "h": "87a56da9355c15cf", "c": "{\n \"schemaVersion\": 1,\n \"manifests\": [\n {\n \"baseUrlPattern\": \"api.anthropic.com\",\n \"serviceType\": \"messages\",\n \"planHint\": \"anthropic_api\",\n \"modelIds\": [\n \"claude-fable-5\",\n \"claude-mythos-5\",\n \"claude-opus-4-8\",\n \"claude-opus-4-7\",\n \"claude-opus-4-6\",\n \"claude-sonnet-5\",\n \"claude-sonnet-4-6\",\n \"claude-sonnet-4-5\",\n \"claude-opus-4-5\",\n \"claude-haiku-4-5\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"api.deepseek.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"deepseek_anthropic\",\n \"modelsUrl\": \"https://api.deepseek.com/models\",\n \"modelIds\": [\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"api.deepseek.com\",\n \"serviceType\": \"openai\",\n \"planHint\": \"deepseek_openai\",\n \"modelsUrl\": \"https://api.deepseek.com/models\",\n \"modelIds\": [\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"api.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_payg_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-cn.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_cn_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-sgp.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_sgp_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"token-plan-ams.xiaomimimo.com/anthropic\",\n \"serviceType\": \"messages\",\n \"planHint\": \"mimo_token_plan_ams_anthropic\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"api.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_payg_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"token-plan-cn.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_token_plan_cn_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"token-plan-sgp.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_token_plan_sgp_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"token-plan-ams.xiaomimimo.com/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"mimo_token_plan_ams_openai\",\n \"modelIds\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\"\n ],\n \"excludeModelPatterns\": [\n \"^mimo-v2\\\\.5-(?:asr|tts(?:-.+)?)$\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"api.kimi.com/coding\",\n \"serviceType\": \"messages\",\n \"planHint\": \"kimi_code_anthropic\",\n \"modelsUrl\": \"https://api.kimi.com/coding/v1/models\",\n \"modelIds\": [\n \"kimi-for-coding\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"api.kimi.com/coding/v1\",\n \"serviceType\": \"openai\",\n \"planHint\": \"kimi_code_openai\",\n \"modelsUrl\": \"https://api.kimi.com/coding/v1/models\",\n \"modelIds\": [\n \"kimi-for-coding\"\n ],\n \"disableProbe\": false\n },\n {\n \"baseUrlPattern\": \"ark.cn-beijing.volces.com/api/plan\",\n \"serviceType\": \"messages\",\n \"planHint\": \"volcengine_plan_anthropic\",\n \"modelIds\": [\n \"doubao-seed-2.0-code\",\n \"doubao-seed-2.0-pro\",\n \"doubao-seed-2.0-lite\",\n \"doubao-seed-2.0-mini\",\n \"minimax-m2.7\",\n \"minimax-m3\",\n \"glm-5.2\",\n \"glm-latest\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-pro\",\n \"kimi-k3\",\n \"kimi-k2.6\",\n \"kimi-k2.7-code\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"ark.cn-beijing.volces.com/api/coding\",\n \"serviceType\": \"messages\",\n \"planHint\": \"volcengine_coding_anthropic\",\n \"modelIds\": [\n \"doubao-seed-2.0-code\",\n \"doubao-seed-2.0-pro\",\n \"doubao-seed-2.0-lite\",\n \"doubao-seed-code\",\n \"minimax-m2.7\",\n \"minimax-m3\",\n \"glm-5.2\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-pro\",\n \"kimi-k2.6\",\n \"kimi-k2.7-code\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"ark.cn-beijing.volces.com/api/plan\",\n \"serviceType\": \"openai\",\n \"planHint\": \"volcengine_plan_openai\",\n \"modelIds\": [\n \"doubao-seed-2.0-code\",\n \"doubao-seed-2.0-pro\",\n \"doubao-seed-2.0-lite\",\n \"doubao-seed-2.0-mini\",\n \"minimax-m2.7\",\n \"minimax-m3\",\n \"glm-5.2\",\n \"glm-latest\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-pro\",\n \"kimi-k3\",\n \"kimi-k2.6\",\n \"kimi-k2.7-code\"\n ],\n \"disableProbe\": true\n },\n {\n \"baseUrlPattern\": \"ark.cn-beijing.volces.com/api/coding\",\n \"serviceType\": \"openai\",\n \"planHint\": \"volcengine_coding_openai\",\n \"modelIds\": [\n \"doubao-seed-2.0-code\",\n \"doubao-seed-2.0-pro\",\n \"doubao-seed-2.0-lite\",\n \"doubao-seed-code\",\n \"minimax-m2.7\",\n \"minimax-m3\",\n \"glm-5.2\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-pro\",\n \"kimi-k2.6\",\n \"kimi-k2.7-code\"\n ],\n \"disableProbe\": true\n }\n ]\n}\n"}}, "docs/design/channel-autopilot.md": {"f6977b2d05d47b726706c52e0ff5e873888ad2a1": {"t": 1785747199.6829631, "n": 167479, "h": "245ceceb056f0f83", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.2.3 同订阅多 Key:能力共享与配额负载均衡\n\n**问题**:官方订阅 plan(Claude Max、Codex plan 等)允许用户持有多个 key,或购买多份相同套餐。若每个 endpoint 独立探测与配置,探测预算被浪费、配置重复;请求量大时还需要在同 plan 的多个 key 之间按官方 RPM 限速自动负载均衡,而不是打满第一个 key 才溢出到下一个。\n\n**能力检测去重(同一套餐只需一套能力检测和配置)**:\n\n```text\nSubscriptionUID 相同的 endpoint 共享一份「订阅级能力画像」:\n 模型列表 / vision / tool / reasoning 支持 / 协议兼容开关 → 只探测一次,\n 存在 SubscriptionProfile.SharedCapability,endpoint 画像以引用方式继承。\n\n仍然 per-endpoint 维护的:健康状态、延迟、熔断、用量窗口\n (key 可能被单独封禁/限速,能力相同不代表运行态相同)\n\n继承失效条件:某 key 实测模型列表哈希与订阅级不一致(GroupChangeDetector 信号)\n → 该 key 脱离继承、独立探测,并在 UI 标记「与套餐能力不一致」警告。\n```\n\n**配额负载均衡(Phase 1 shadow 展示,Phase 2 生效)**:\n\n```text\n每 key 维护配额余量视图:\n remainingRPM = officialRPM − 当前窗口已用\n officialRPM 来源优先级:内置 manifest > 用户配置 > RateLimitDiscoverer 建议\n\n同订阅多 key 时按「剩余余量加权」分配请求(余量大者优先、\n窗口最早重置者兜底),替代顺序打满:\n 仅当渠道 autoManaged 且同一 SubscriptionUID 下 key 数 > 1 时启用;\n 否则保持现有 sequential/random key 顺序策略不变。\n\n某 key 的窗口配额耗尽 → 临时降权并切到同订阅其他 key,\n不熔断整个渠道(这是配额耗尽,不是故障)。\n```\n\n### 3.2.4 订阅用量窗口 (Usage Windows)\n\n部分官方 plan 提供 5h / 日 / 周 / 月等窗口的用量查询与限制(如 Claude 订阅的 5h 滚动窗口 + 周配额)。在 key 的位置展示这些窗口,帮助用户掌握使用情况:\n\n```go\ntype UsageWindow struct {\n Window string `json:\"window\"` // \"5h\" | \"day\" | \"week\" | \"month\"\n Used float64 `json:\"used\"` // 已用量\n Limit float64 `json:\"limit\"` // 上限,0 = 未知\n Unit string `json:\"unit\"` // requests | tokens | credits | percent\n ResetsAt time.Time `json:\"resetsAt\"` // 窗口重置时间\n Source string `json:\"source\"` // official_api | response_header | local_metering\n FetchedAt time.Time `json:\"fetchedAt\"`\n}\n// KeyEndpointProfile 与 SubscriptionProfile 均增加 UsageWindows []UsageWindow\n```\n\n数据来源优先级:\n\n```text\n1. 官方用量 API:有 provider adapter 的 plan 主动拉取(§Phase 4 订阅 adapter 的可提前子集)\n2. 响应头:anthropic-ratelimit-* / x-ratelimit-* 等(复用 RateLimitDiscoverer 的信号 tap)\n3. 本地计量兜底:CCX 按 key 累计请求数/token(误差来自窗口起点对齐,标注 Source=local_metering)\n```\n\nUI 要求:key 行内显示主窗口(5h)用量进度条,悬浮展开全部窗口;用量 >80% 黄色预警、超限红色并显示预计重置时间。调度联动(Phase 2):窗口余量进入配额负载均衡权重,窗口耗尽的 key 临时降权而非熔断。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n#### 4.2.1 内置模型清单 (Builtin Models Manifest)\n\n**问题**:部分官方订阅 plan 的入口没有正确的 models 接口——要么不存在(404)、要么需要额外权限、要么返回与订阅实际可用模型不符的列表(典型:Claude 订阅 OAuth 入口、Codex plan 入口、部分官方企业网关)。对这些 baseURL 做模型发现会失败或产出错误画像。\n\n**方案**:CCX 内置「已知官方 baseURL → 模型清单」映射表,命中时直接返回内置内容,完全跳过上游 models 接口:\n\n```go\n// backend-go/internal/config/builtin_models_manifest.go(生成式,随模型注册表更新)\ntype BuiltinModelsManifest struct {\n BaseURLPattern string `json:\"baseUrlPattern\"` // 精确 host 或前缀匹配,如 \"api.anthropic.com\"\n ServiceType string `json:\"serviceType\"` // messages/responses/…\n PlanHint string `json:\"planHint,omitempty\"` // 订阅类型提示(如 \"claude_subscription\")\n ModelIDs []string `json:\"modelIds\"` // 该入口实际可用的模型清单\n DisableProbe bool `json:\"disableProbe\"` // true 时 Discovery 跳过 GET /v1/models\n}\n```\n\n**两个生效位置**:\n\n```text\n1. Discovery(§4.2 流程第 2 步之前):\n baseURL 命中 manifest 且 DisableProbe=true\n → 跳过 GET /v1/models,直接用 ModelIDs 生成 ModelProfile[]\n → ProfileSource = \"builtin_manifest\",不再因 models 接口失败而误判 misconfigured\n\n2. /v1/models 代理响应(现有 Models API):\n 客户端向 CCX 请求 /v1/models 时,聚合结果中该渠道的贡献\n 直接取内置清单,不向上游发起 models 请求\n (渠道显式配置了 supportedModels 时仍以用户配置优先)\n```\n\n**维护与覆盖顺序**:\n\n```text\n渠道显式 supportedModels(用户手动) > 内置 manifest > 上游 models 接口实探 > 内置候选模型回退\n```\n\nmanifest 与模型注册表同源生成(`generated_model_registry.go` 同级),新模型发布时随注册表一起更新;HealthAnalyzer 对命中 manifest 的 endpoint 不将「models 接口 404/403」计入 misconfigured 信号。\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n MappedEffort EffortLevel // 自动映射的 effort 级别(空=未决定)\n MappedEffortDecided bool // effort 是否由 Autopilot 决定\n EndpointUID string // 稳定 endpoint ID\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n\n // ResolvedModelByEndpointUID(旧 API):仅返回模型名,向后兼容。\n // ResolvedTargetByEndpointUID(新 API):返回 model + effort 原子决策。\n // 两者从同一份 targetByUID 派生,避免两个真值源。\n ResolvedModelByEndpointUID func(endpointUID string) string\n ResolvedTargetByEndpointUID func(endpointUID string) *ResolvedRouteTarget\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20;assist 下 dead/misconfigured 额外 -100 并保留在队尾\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法细分时进入 `general`;有规范 Knowledge 基准则使用其软评分,缺少直接证据时仍取 0.5 中性值。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n**手动 L3 第一版状态(2026-07-15)**:已实现 `pq-v1-20260715` 固定 JSON canary,覆盖\nMessages、Chat Completions、Responses 与 Gemini 上游协议。调用方只能指定 `endpointUid`、\n`modelId` 和 1~3 次重复数,不能注入 prompt。评分仍按 40% 完整性、30% 语义正确性、\n15% 严格 JSON 格式、15% 延迟执行;第一版使用非流式完整响应延迟作为首 token 延迟代理,\n后续引入流式采样时再替换该维度。单次有效样本 confidence=0.6,最多三次提升到 0.8;\n失败样本按 0 分计入均值,全部失败不写画像。\n\n安全边界:仅管理 API 手动触发,不启动自动 worker;当前进程按 UTC 日设置 12 次硬预算,\n请求前原子预留,单次 90 秒超时、响应体上限 64 KiB。同一 endpoint×model 不并发执行;\n单次请求最多允许 2048 个输出 token,以容纳推理模型的隐藏思考并限制最坏额度消耗。\n对声明可控推理的模型固定采用最低可用 effort;Claude 兼容端点若只暴露\n`reasoning_content` 而未声明 thinking 模式,则与 capability-test 一致显式关闭 thinking。\n原始模型输出和明文 Key 既不落盘也不返回,只保存分数、canary 版本、置信度和脱敏判定。\n`user_feedback` 仍高于 `probe`,自动发现重建 ModelProfile 时必须保留既有质量证据。\n跨进程共享预算与 LLM-as-judge/语义向量评分仍属于后续阶段。\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n\n乘数是预设内建的常量,不对外暴露;非法或未知 mode 归一化回退 `balanced`。\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法细分的通用任务;缺少基准证据时中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general;有规范 Knowledge 基准时使用其软评分,缺少直接证据时取 0.5 中性。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? canonicalBenchmark[model][domain] * providerQualityFactor\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 规范模型基准 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n#### 5.7.4 规范模型能力基准\n\n`shared/model-registry/ccx_model_registry.json` 的 `benchmarkProfiles` 独立保存规范模型能力上界,不与 `upstreamCapabilities` 混合。后者仍是 context、vision、tool calls 等硬能力的事实源;benchmark 只参与 Autopilot 软评分和 trace 解释,绝不改变 `supportedModels` 或 CapabilityFloor。\n\n```text\nEffectiveCapability[domain]\n = CanonicalCeiling[model, domain]\n × ProviderQualityFactor[endpoint]\n\nProviderQualityFactor = 1 - confidence × (1 - providerQualityScore)\n无渠道质量证据或 confidence<0.5 时 factor=1;可信证据只能从规范模型上界向下折算。\n```\n\n基准数据必须保留领域向量、来源、核验日期、lane、共享结果数和可比类别覆盖率,不能只保存站点 Overall。当前 BenchLM 接入为 `provisional`:Claude Opus 4.8、GPT-5.6 Terra、GPT-5.6 Sol 各有 30 个共享结果,8 类中 5 类可比,核验日期为 2026-07-13。\n\n领域映射:Knowledge → `general`,Math → `reasoning`,Coding → `coding` / `code_review`,Agentic → `agentic`,Multimodal → `aesthetics_ui`(仅弱代理,解释置信度减半)。Writing / Translation 没有直接类别证据时继续回退家族种子或 0.5。BenchLM 的 cohort 归一化、缺失项插补和外部校准决定了 Overall 不是绝对能力值,因此路由只消费可映射的原始类别分。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n#### 5.8.4 联合决策的现状实现(model + effort 原子化)\n\n> 状态说明(2026-07-26):本节描述的联合决策链路已实现并接入真实请求路径(关联计划 [2026-07-26-autopilot-model-effort-joint-routing.md](../superpowers/plans/2026-07-26-autopilot-model-effort-joint-routing.md) 的 Task 1~7)。**渠道级手工 `modelMapping` / `reasoningMapping` 表目前仍然存在且仍然生效**,退役是该计划的 Task 8,尚未执行;手工表当前的优先级关系见下文。\n\n**决策对象。** 自动路由的返回值不再是裸模型名,而是 `autopilot.ResolvedRouteTarget`(`backend-go/internal/autopilot/route_target.go`):\n\n```go\ntype ResolvedRouteTarget struct {\n Model string // 目标模型 ID\n Effort EffortLevel // 目标思考档位;空串表示不改写\n EffortDecided bool // true 表示 effort 由 Autopilot 决定;false 为 passthrough\n Reason string // 决策原因,用于 Trace 与日志\n}\n```\n\n`EffortDecided=false` 且 `Effort` 为空是合法的 fail-open 结果:模型可能被改写,但思考参数保持原样。\n\n**决策发生的位置。** `autopilot/endpoint_policy.go` 的 `resolveMappedModel()` 是统一入口,内部分两路:`resolveManualMapping()` 处理渠道级显式 `ModelMapping`(仍是当前优先级更高的一路,返回的 `ResolvedRouteTarget.EffortDecided` 恒为 `false`,即手工映射只改模型不改档位);`resolveAutoModel()` 调用 `ModelResolver` 做自动模型解析,并在 `model_resolver.go` 的 `rankEligibleModels()` 中按 `SupportedEffortLevels` × `PerTaskClass` 展开 `(model, effort)` 候选、应用 `EffortQualityBonus` 与 anti-effort-inflation 反膨胀 tiebreak(同质量档优先选低 effort)、并按 `EffortFloor` 过滤掉低于任务下界的候选。产出的 `ResolvedRouteTarget` 经 `buildResolvedTargetLookup()` 挂到 `EndpointAttemptPolicy.ResolvedTargetByEndpointUID`,与旧的 `ResolvedModelByEndpointUID`(仅模型,向后兼容)并列。\n\n**决策如何到达上游请求。** `handlers/common/upstream_failover.go` 的 `TryUpstreamWithAllKeys` 在为每个 endpoint 尝试构建请求前,通过 `ResolvedTargetByEndpointUID` 取得 `*ResolvedRouteTarget`,交给 `atomicModelEffortRewrite()` 做一次原子改写:先用 `sjson` 改写 `model` 字段;若 `EffortDecided=true` 且 `Effort` 非空,再检查目标模型的 `ThinkingMode`——`adaptive_only` 的模型跳过思考参数注入(模型自身决定思考深度,守卫逻辑对齐 `providers/claude.go` 的 `applyClaudeReasoningEffort`),否则调用 `config.ApplyReasoningParamStyle()` 写入。模型改写与档位改写共用一次函数调用,任一步 `sjson`/`json` 处理失败即整体放弃、保持原始 body,不会出现\"模型改了、档位没改\"的中间态。\n\n**各协议的注入形态。** `config.ApplyReasoningParamStyle(req, style, effort)`(`config/config_utils.go`)按 `upstream.ReasoningParamStyle` 三态分支:\n\n```text\nstyle=\"thinking\" → Claude 派系:写 thinking.type=enabled + thinking.effort=;\n effort 为 off/none 时写 thinking.type=disabled;同时清理互斥字段\n reasoning / reasoning_effort / thinking.budget_tokens\nstyle=\"reasoning_effort\" → OpenAI Chat 派系:写顶层 reasoning_effort 字段,清理 reasoning\nstyle=\"\" / 默认 → Responses 派系:写 reasoning.effort 对象形态\n```\n\n**Gemini 派系是尚未接通的缺口。** 请求侧提取(`extractGeminiEffortExplicit()`,`handlers/common/reasoning_log.go`)已经能读取 `generationConfig.thinkingConfig.thinkingLevel` 并识别显式 `none`(`thinkingBudget=0`),这条读路径用于日志与 `RequestProfile.ClientEffort` 提取。但写路径——把联合决策的 `target.Effort` 写回 Gemini 请求体的 `thinkingConfig.thinkingLevel`——目前代码中不存在:`atomicModelEffortRewrite()` 唯一的写出口是 `ApplyReasoningParamStyle()`,它只覆盖 Claude/Chat/Responses 三种形态,没有 Gemini 分支;`handlers/gemini/handler.go` 的 `buildProviderRequest()` 也没有调用任何 effort 注入函数。也就是说,Gemini 渠道当前仍会经过 endpoint 级模型改写(`atomicModelEffortRewrite` 会改写 `model` 字段本身),但联合决策选中的思考档位不会落到 Gemini 请求体上,效果等同于 fail-open passthrough。此项在原计划里归属 Task 5 的\"Gemini 派系注入路径\",尚未实现。\n\n**可观测性字段。** 原子改写点会向 `gin.Context` 写入两个字段供 ChannelLog 使用:`effortDecisionSource`(`autopilot` 表示由联合决策产出,`passthrough` 表示未改写或手工映射路径)、`effortClampedByClient`(当客户端显式声明的档位低于 Autopilot 选择的档位时置 `true`,由 `isEffortClampedByClient()` 基于 `EffortLevelOrdinal` 比较得出)。\n\n**手工映射表的现状。** `resolveMappedModel()` 当前的分支顺序是\"显式 `ModelMapping` 优先,自动决策兜底\"——与原计划 Task 7 描述的\"反转优先级\"目标相反的方向仍是默认值;反转开关已作为可配置项落地(提交 `d15e776e`),但默认状态未切换,`ModelMapping` / `ReasoningMapping` 两张表本身尚未删除,`config.ResolveReasoningEffort()`(`config/config_utils.go`)与 provider 层(如 `providers/claude.go`)仍会读取 `ReasoningMapping` 独立生效。表的整体退役是 Task 8 的范围,未开始。\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**请求意图边界(ModelRoutingPolicy)**\n\n跨模型替代不是所有模型的默认能力,只对白名单客户端入口开放:\n\n```text\nmessages:\n fable / opus / sonnet / haiku 及对应 claude-* 完整模型名 → adaptive\nresponses:\n gpt-5.6* / gpt-5.5 / gpt-5.4* / codex-auto-review → adaptive\n其他协议或模型:\n exact-only,只能在探测到相同规范化模型 ID 的 endpoint 之间调度\n```\n\n`adaptive` 允许在满足 Capability Floor 后选择 GLM、MiMo 等其他模型族;`exact-only` 禁止跨模型替代。用户显式配置的 `modelMapping` 仍优先于该策略。调度预览和 endpoint 实际解析必须调用同一分类函数,避免诊断结果与真实请求分叉。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ModelProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选优\n // 上下文窗口只负责准入,不对更大的窗口扣分。\n // 排序优先级:\n // a. 模型质量档越高越优先\n // b. provider 已确认的同档模型能力顺序\n // c. 带置信度折算的供应商实测质量\n // d. 探测延迟\n // e. 同渠道/provider 的模型相对消耗倍率\n // 倍率相同或缺失时再比较公开模型成本\n // f. 同模型族与 model ID 仅作为确定性兜底\n best := rankEligibleModels(eligible, requestModel)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n同渠道套餐倍率与公开 USD/Token 价格是不同单位,禁止直接混算。模型解析器先比较 provider\n提供的相对消耗倍率;只有双方倍率相同,或双方均无倍率时,才回退到统一输入/输出规模下的\n公开价格。优云智算当前内置倍率如下(数字越小越省,`glm-5.2` 的 2 次为限时值):\n\n| 模型 | 每次调用套餐消耗 |\n|------|-----------------:|\n| `glm-5.2` | 2 |\n| `glm-5.1` | 6 |\n| `MiniMax-M2.7` | 2 |\n| `kimi-k2.6` | 5 |\n| `deepseek-ai/DeepSeek-V3.2` | 1 |\n| `deepseek-v4-flash` | 1 |\n\nprovider 可以额外声明同一质量档内已确认的模型能力顺序,但只有该档全部候选都命中该元数据\n时才比较,避免把未知模型误判为弱模型,也保证排序不依赖候选输入顺序。优云模板当前记录\n`glm-5.1 > kimi-k2.6`,因此前者即使消耗 6 次,也会在后者的 5 次成本证据之前胜出。\n`glm-5.2` 即使消耗 2 次,也仍会在质量档高于 DeepSeek 候选时优先;V3.2 与 V4 Flash\n同为 1 次时,再由公开价格及最终确定性规则区分。静态 `modelMapping` 仅保留为用户显式覆盖,\n不承担自动替代策略。\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - autoManaged 的空 supportedModels 不表示支持全部,必须由 endpoint ModelProfile 裁决\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n当 ModelResolver 已正常初始化且路由处于 `assist/auto + autoResolve=true` 时,autoManaged 渠道的画像拒绝是权威结果,调度器不得再 fail-open 到空 `supportedModels` 的兼容语义。Resolver 不可用、开关关闭或 `off/shadow` 时仍保留原有 fail-open,保证急停和降级路径不依赖画像库。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n### 6.9 渠道兼容性统一自学习\n\n渠道级兼容性配置不应要求用户先踩坑、再去翻设置面板找开关。这一节描述如何把这些开关从\n「静态布尔」转为「运行时学习到的能力事实」。\n\n**优先级合成**(`config.resolveCompatSwitch`)\n\n```text\n最终生效值 = 学习结论 ?? 历史种子(14 天内) ?? 静态默认\n```\n\n六个兼容性字段(`NormalizeNonstandardChatRoles`、`StripImageGenerationTool`、\n`CodexNativeToolPassthrough`、`StripEmptyTextBlocks`、`PassbackReasoningContent`、\n`PassbackThinkingBlocks`)已从 `UpstreamConfig`/`UpstreamUpdate` 整体删除:管理面板不提供编辑\n入口,渠道更新接口不接受手工写入,完全交由运行时自动决策。不再有「用户当前显式设置」这一档——\n历史手工值不代表可信的长期事实,可能当初就设错,也可能上游后来有了新情况已不再适用,因此不作为\n可以无限期压过学习的强制覆盖,只作为学习结论缺失时的一次性低置信度提示。\n\n学习结论通过 `UpstreamConfig.LearnedCompatTraits`(运行时字段,`json:\"-\"`,不落盘)传递:\nfailover 在发送前按 渠道-Key-模型 查询 `ChannelCompatCache`,把结论注入当次请求专用的\nupstream 副本,provider 构造请求时读取。\n\n**历史手工配置降级为种子**(`migrateManualCompatSwitchesToSeeds`):老用户配置文件里的六个字段\n已从结构体删除,只能在启动时从磁盘原始 JSON 里读到历史手工值。迁移把这些值降级为\n`UpstreamConfig.CompatSeeds`(`map[string]CompatSeedEntry`,带 `ExpiresAt`):\n\n- 种子带 **14 天有效期**(`CompatSeedTTL`),过期后自动失效、不再参与判断,回落学习结论或静态\n 默认重新判断。种子与学习记忆分开存储且 TTL 不同(种子 14 天 vs 学习记忆 24 小时),因为二者\n 语义不同:种子是\"曾经的一个信号\",学习记忆是\"渠道-Key-模型级的当前能力事实\"。\n- 迁移保留 `false` 值:用户「显式关闭」同样是他曾观察到的行为证据。\n- 迁移当下不改变可感知行为(学习缺失时按种子生效),但学到真实结论后立即让位,种子过期后也\n 自动退场。\n- 幂等:已有种子的渠道跳过。\n- 自动托管渠道在 `RuntimeUpstreamForAutoManagedProvider` 里连种子一起清空,其兼容性完全由\n 厂商原生默认值加运行时学习决定。已知厂商协议事实(如 GLM 的 OpenAI 兼容协议要求\n `passbackReasoningContent`)不走种子这条给\"可能出错的历史手工值\"用的临时通道,而是在对应\n accessor 里按 `ProviderID`/`ServiceType` 直接计算为静态默认(`shouldPassbackReasoningContentByDefault`\n 之类),因为这是不会过期的协议事实而非猜测。\n\n**记忆载体**:`config.ChannelCompatCache`,落盘 `.config/channel_compat.json`,\n键 `channelUID:keyHash:model`,TTL 24h(与 `DeprecatedParamCache`/`SystemHeaderFilterCache` 一致)。\n`Record` 仅在结论新增或翻转时返回 `true`,调用方据此决定是否重试,防止记忆生效后死循环。\n\n**三条学习路径**(按上游信号强度区分,不可一概而论)\n\n| 信号类型 | 兼容项 | 学习方式 |\n| --- | --- | --- |\n| 硬 400/422 + 可正则匹配 | developer role 降级、Codex 专属工具剥离、历史 thinking 回传、图片工具剥离 | 错误驱动:`CompatTraitFromError` 识别 → 记忆 → 同 Key 立即重试 |\n| 无报错,仅响应形态差异 | `passbackReasoningContent`、`stripEmptyTextBlocks` | 探针驱动:首次遇到该组合时 singleflight 后台探测一次 |\n| 厂商文档已公布约束 | Kimi 固定值参数、`tool_choice`、`thinking` | 前置规避:`ApplyKnownParamConstraints` 发送前直接改写,无需失败一次 |\n\n**分层职责**:`upstream_failover` 负责识别信号、写缓存、编排重试、把结论注入 `upstreamCopy`;\n各 provider 在构造上游请求时读取合成值并执行改写。这样 developer role 降级发生在\nResponses→Chat 转换**之后**的 `reqMap` 上,避开「failover 层拿到的是 Responses `input` 而非\nChat `messages`」这个陷阱。\n\n**防误判约束**(学错并持久化比不学更糟)\n\n- 双条件门控:正则命中之外,还要求请求侧确实携带对应结构(`BodyHasDeveloperRole` 等)\n- 单次重试上限:`Record` 返回 `false` 即不再重试\n- 不跨维度污染:结论不外溢到同渠道其他 Key/模型\n- 仅 400/422 参与学习;429/5xx/超时属容量问题,不是能力问题\n- fail-open:缓存损坏、探测失败一律退化为静态默认,不阻断代理请求\n\n**图片工具归因修正**:图片生成受限时,原实现直接拉黑 (Key,模型)。现改为先学习\n「需剥离图片工具」并重试修复请求,只有剥离后仍失败才回落拉黑——从「惩罚渠道」改为「先修复请求」。\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\nGET /api/health-center/provider-quality/budget → 查看本进程手动 L3 每日预算\nPOST /api/health-center/provider-quality/probe → 对指定 endpoint×model 执行固定质量 canary\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n#### 8.0.1 实现现状与演进方向(2026-07-09 核实)\n\n四界面在 **Web 前端(`frontend/src`)已大面积落地**,不是纯文档:\n\n| 界面 | Web 实现 | Desktop 实现 |\n|------|----------|--------------|\n| 健康中心 | ✅ `HealthCenterView.vue` + `HealthCenterStats`/`HealthChannelTable`/`HealthChannelDetail`/`ProfileChangelogTimeline`,路由 `/health` | ❌ 无 |\n| 订阅中心 | ✅ `SubscriptionsView.vue` + `SubscriptionPlanTable`,CRUD + 刷新余额,路由 `/subscriptions` | ⚠️ 独立旧实现 `subscriptions/SubscriptionTab.vue`(与 Web 两套代码) |\n| 渠道中心 | ✅ `ChannelOrchestration.vue`(六态 `ChannelHealthBadge`、来源 chips、`autoManaged` 图标齐备) | ❌ 未同步 |\n| 管理面板 | ⚠️ `AutopilotModePanel.vue` 仅暴露 mode/killSwitch/costPreference | ❌ 无 |\n| 驾驶舱 | ✅ `CockpitView.vue`(观测型总览:健康统计+余额汇总+渠道推荐) | ⚠️ `cockpit` tab 渲染的是会话 Dashboard,职责不同 |\n\n**与本设计意图的关系**(对齐用户「自动调度为主、废旧手动驾驶舱」的诉求):\n- **旧的「手动调度驾驶舱」在 Web 端已不存在**。`CockpitView` 已是 §8.7 定义的观测型总览,只回答健康/省钱/风险/待办四问题,不做渠道配置编辑,符合「程序自动选、人只观测+有限干预」的方向。无需再废除,只需继续收敛为纯观测。\n- **统一提交入口已存在**(§8.4 快速添加 + §8.5 订阅中心),new-api 集成(§8.5.1)在此之上把「填 baseURL+key」进一步自动化。\n\n**前端演进待办**(本设计新增/明确):\n1. **补 `ManualRoutingIntent` 前端**:这是用户要的「有限偏向性干预」,后端已有、两个前端均未实现(§8.3 试用意图入口尚缺)。\n2. **Desktop 前端对齐 Web**:健康中心/渠道自动托管/管理面板/观测型驾驶舱在 Desktop 几乎空白,需同步或复用 Web 组件,避免长期维护两套。\n3. **管理面板补字段**:暴露 `l2ProbeEnabled`、探测预算、new-api/多来源订阅 adapter 开关(§8.5.2)等,当前只有 3 个字段。\n4. **订阅中心接 §8.5.1/§8.5.2**:新增「new-api」订阅类型与 Provider Adapter 能力矩阵驱动的动态表单。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.5.1 new-api 订阅集成(统一账号接入)\n\n**目标**:用户在订阅中心手填一个 new-api(及其 fork)账号的「系统访问令牌」即可,CCX 自动用该令牌创建代理专用 key、拉取分组倍率、查询额度余额,并把「站点 baseURL + 新建 key」自动落成一个上游渠道,交给 Autopilot 探测与调度。\n\n**范围约束**:\n- 只使用 new-api 用户在「个人设置」生成的 **系统访问令牌(access_token)** 一种凭据;令牌用于 API 调用的身份验证,属敏感数据,须妥善加密存储。\n- **不做** cookie 登录 + 2FA + 导出后台渠道明文 key 的管理员迁移路径(all-api-hub 的 newApiSession 能力),本设计不涉及。\n- new-api 归入 `OriginType=relay`、`OriginTier=second`(中转站),信任等级不因自动化提升;实时质量仍由运行画像决定。\n- 认证令牌像其他订阅凭据一样由用户手动录入,CCX 不代替用户在 new-api 侧注册或登录。\n\n**认证与凭据模型**:\n\nnew-api family 的管理接口鉴权(依据 all-api-hub 传输层实现):\n\n```text\nAuthorization: Bearer # 系统访问令牌;raw 模式可去掉 Bearer 前缀\nNew-API-User: # 当前用户 ID(必带)\nUser-id: # fork 兼容回退(Veloera/voapi/Super-API 等)\nContent-Type: application/json\n```\n\n统一响应信封:`{ \"success\": bool, \"data\": T, \"message\": string }`;解析时先判 `success`,再取 `data`。\n\n**SubscriptionProfile 扩展**:\n\n在 §3.2.2 的 `SubscriptionProfile` 基础上,为可自动接入的订阅新增 provider 集成字段(沿用「默认关闭 + 显式 opt-in」守则):\n\n```go\n// SubscriptionProfile 新增字段(复用 Phase 4 Item 6 的 SubscriptionAutoRefresh 开关)\ntype SubscriptionIntegration struct {\n Kind string `json:\"kind\"` // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | ...\n BaseURL string `json:\"baseUrl\"` // new-api 站点地址(同时作为新建渠道的上游 baseURL)\n AccessToken string `json:\"-\"` // 系统访问令牌;加密存储,不出 API 响应\n UserID string `json:\"userId\"` // New-API-User 头所需\n AuthTokenMode string `json:\"authTokenMode,omitempty\"` // \"bearer\"(默认) | \"raw\"\n // 自动建 key 的模板\n ProvisionKeyName string `json:\"provisionKeyName,omitempty\"` // 默认 \"ccx-autopilot\"\n ProvisionGroup string `json:\"provisionGroup,omitempty\"` // 建 key 时指定分组,空=默认分组\n ProvisionModels []string `json:\"provisionModels,omitempty\"` // model_limits,空=不限制\n // 自动同步产物(回填,只读展示)\n ProvisionedTokenID int `json:\"provisionedTokenId,omitempty\"`\n LinkedChannelUID string `json:\"linkedChannelUid,omitempty\"`\n}\n```\n\n`AccessToken` 必须与现有敏感字段一样走加密存储,序列化到前端时脱敏(仅显示尾部若干位)。\n\n**new-api 接口清单**(本集成实际调用的子集,路径以主线 new-api 为准,字段名保持与上游一致):\n\n| 用途 | 方法 | 路径 | 请求体 | 响应关键字段 |\n|------|------|------|--------|--------------|\n| 校验令牌 + 查用户/余额 | GET | `/api/user/self` | — | `data.id`、`data.username`、`data.quota`(剩余额度)、`data.used_quota` |\n| 拉分组倍率 | GET | `/api/user/self/groups` | — | `data: { : { desc, ratio } }` |\n| 站点全部分组 | GET | `/api/group` | — | `data: string[]` |\n| 账号可用模型 | GET | `/api/user/models` | — | `data: string[]` |\n| 创建代理 key | POST | `/api/token/` | `CreateTokenRequest`(见下) | `data.key` / `success` |\n| key 列表 | GET | `/api/token/?p={p}&size={n}` | — | `data.items[]`(含 `id/key/name/status/remain_quota`) |\n| 更新 key | PUT | `/api/token/` | `{ ...token, id }` | `success` |\n| 删除 key | DELETE | `/api/token/{id}` | — | `success` |\n\n`CreateTokenRequest`:\n\n```json\n{\n \"name\": \"ccx-autopilot\",\n \"remain_quota\": 0,\n \"expired_time\": -1,\n \"unlimited_quota\": true,\n \"model_limits_enabled\": false,\n \"model_limits\": \"\",\n \"allow_ips\": \"\",\n \"group\": \"\"\n}\n```\n\n`quota` 是 new-api 内部额度单位(非美元),换算比例读 `GET /api/status` 的 `price` / `PaymentUSDRate`(无鉴权),或允许用户在订阅中手填换算率;MVP 直接以 quota 原值展示 + 站点换算率估算余额。\n\n**接入流程**(订阅中心「新增订阅 → 类型选 new-api」):\n\n```text\n1. 用户输入:站点 baseURL + 系统访问令牌(access_token) + userId\n (userId 可选自动获取:先用令牌调 /api/user/self 拿 data.id 回填)\n2. 校验:GET /api/user/self\n ├─ success=false / 401 → 令牌无效或 userId 不匹配,报错停止\n └─ 成功 → 记录 username、quota,创建 SubscriptionProfile(provider=new_api, originTier=second)\n3. 拉分组:GET /api/user/self/groups → 写入 GroupMultipliers({group: ratio})\n GET /api/user/models → 记录账号可用模型,供渠道 supportedModels 参考\n4. 建 key:POST /api/token/(按 ProvisionKeyName/Group/Models 模板)\n ├─ 已存在同名 ccx-autopilot key → 复用其 id,不重复创建(列表比对 name)\n └─ 新建成功 → 回填 ProvisionedTokenID,取 data.key(补齐 sk- 前缀)作为渠道 apiKey\n5. 写入渠道:同 baseURL+channelKind 已有渠道时并入该渠道(多 new-api 订阅与纯 key 整合为同一渠道),否则自动新建\n ├─ 合并目标匹配:BaseURL/BaseURLs 规范化(去尾部 /,忽略大小写)相等;跳过带 providerId 的渠道;多个命中优先 active\n ├─ 合并:保留已有纯 key 与配置,新 key 按字符串去重后追加,写入 quotaGroup/groupMultiplier 元数据\n ├─ 新建:channelKind 由用户选择或按站点探测(messages/chat/responses/gemini),status=unknown,autoManaged=true,OriginType=relay\n ├─ 建立 SubscriptionUID → ChannelUID 链接(回填 LinkedChannelUID)\n └─ 触发 §8.4 的 Discovery + 能力测试标准流程\n6. 完成:订阅卡片显示余额、分组倍率、已绑定渠道、探测进度\n```\n\n关键点:new-api 集成**不额外造探测逻辑**,第 5 步之后完全复用现有 §8.4「添加并探测」链路;集成层只负责「令牌 → key + 分组 + 余额 + baseURL」的自动填充,把手填 baseURL/key 的动作自动化掉。\n\n**刷新与同步**(复用 `POST /api/subscriptions/{uid}/refresh`):\n\n| 触发 | 动作 |\n|------|------|\n| 手动刷新余额 | `GET /api/user/self` 更新 `Balance` + `BalanceUpdatedAt` |\n| 定时刷新(可选,opt-in) | 复用 Phase 4 Item 6 `SubscriptionAutoRefresh`;new-api 作为 `relay` provider adapter 纳入,默认关闭 |\n| 分组倍率变更 | 刷新时对比 `/api/user/self/groups`,`GroupMultipliers` 变化 → 更新并提示「套餐倍率已变」 |\n| key 失效检测 | 渠道侧探测到该 key 全 401 → 订阅卡片提示「key 可能已删除/禁用」,提供「重新建 key」一键动作(重跑第 4-5 步,复用旧渠道,只换 apiKey) |\n| 可用模型变更 | `/api/user/models` 结果哈希变化 → 触发对应渠道重新 Discovery |\n\n**安全与边界**:\n- `access_token` 权限等同 new-api 账户,须加密存储、脱敏展示、禁止写入日志;刷新失败连续 N 次自动停用该订阅的自动刷新,只保留手动。\n- 自动建 key 默认 `unlimited_quota=true` 但可由用户改为限额模板;建 key 是有副作用的写操作,须在接入前明确告知用户「将在 new-api 侧创建一个名为 ccx-autopilot 的令牌」。\n- new-api 是 `OriginTier=second`,**不得**用作隐私敏感的 routing advisor / classifier(遵守 §3.2.1 约束)。\n- fork 兼容:请求同时带 `New-API-User` 与 `User-id` 头;响应信封字段名(`success/data/message`)在部分 fork 可能不同,adapter 需容错解析,失败时回退手动录入。\n- 一个 new-api 订阅默认只自动建/绑定一个渠道;用户如需该站多协议渠道,可在同订阅下手动增建,共用同一 key。\n\n### 8.5.2 订阅 Provider Adapter 框架(多来源余额与接入)\n\n§8.5.1 的 new-api 集成不是特例,而是「订阅 Provider Adapter」框架的一个实现。Phase 4 Item 6 已落地 openai/anthropic/google 的余额刷新 adapter,本框架把它抽象成统一接口,让 new-api 及后续来源(GitHub Copilot、Codex、Nvidia、MiniMax、DeepSeek 等)按相同契约接入,避免每加一个来源就散落一套逻辑(DRY)。\n\n**统一接口**:\n\n```go\n// backend-go/internal/autopilot/subscription/adapter.go\ntype ProviderAdapter interface {\n Kind() string // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | \"copilot\" | \"deepseek\" | ...\n\n // 校验凭据,返回账号身份(用户名/账户标识);失败即凭据无效\n Verify(ctx context.Context, cred Credential) (AccountInfo, error)\n // 查余额/额度;不支持返回 ErrUnsupported,由 UI 降级为手动录入\n FetchBalance(ctx context.Context, cred Credential) (Balance, error)\n // 拉分组倍率;仅 new-api 类中转有,官方直连返回 ErrUnsupported\n FetchGroups(ctx context.Context, cred Credential) (map[string]GroupRatio, error)\n // 自动创建代理 key;官方直连通常不支持(key 即用户 API key),返回 ErrUnsupported\n ProvisionKey(ctx context.Context, cred Credential, tmpl KeyTemplate) (ProvisionedKey, error)\n // 可用模型列表;用于渠道 supportedModels 参考\n FetchModels(ctx context.Context, cred Credential) ([]string, error)\n}\n```\n\n每个能力独立、可选:adapter 只实现自己支持的部分,不支持的统一返回 `ErrUnsupported`(接口隔离原则),UI 据此隐藏/降级对应入口。所有 adapter 共用 `SubscriptionAutoRefresh` 的 opt-in 开关与 `Validate()` 兜底,与 Phase 4 现有防御模式一致。\n\n**能力矩阵**(✅ 支持 / ⚠️ 部分或需确认 / ❌ 不支持,走手动):\n\n| 来源 | Kind | 验证 | 查余额 | 拉分组 | 自动建 key | OriginTier |\n|------|------|:----:|:------:|:------:|:----------:|------------|\n| new-api family | `new_api` | ✅ | ✅ quota | ✅ | ✅ | second |\n| OpenAI 官方 | `openai` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Anthropic 官方 | `anthropic` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Google 官方 | `google` | ✅ | ⚠️ | ❌ | ❌ | first |\n| GitHub Copilot | `copilot` | ⚠️ token | ❌ 无额度概念 | ❌ | ❌ | first |\n| Codex plan | `codex` | ⚠️ | ⚠️ 用量窗口 | ❌ | ❌ | first |\n| Nvidia NIM | `nvidia` | ✅ | ⚠️ credits | ❌ | ❌ | first |\n| MiniMax | `minimax` | ✅ | ⚠️ | ❌ | ❌ | first |\n| DeepSeek | `deepseek` | ✅ | ✅ balance API | ❌ | ❌ | first |\n\n说明:官方直连来源普遍**不支持自动建 key**(用户的 API key 本身就是凭据,直接作为渠道 apiKey 录入即可)、也**没有分组倍率**(价格按官方定价表)。它们的 adapter 价值集中在「验证 + 查余额/用量窗口」,把 §3.2.4 UsageWindow 的官方用量 API 来源坐实。DeepSeek 有独立余额查询端点,可完整支持余额。Copilot 属订阅制、无 token 余额概念,只做验证 + 用量提示。\n\n**接入分层**(YAGNI:不强求一次做全,按价值排期):\n\n```text\nP0(本轮):new_api(完整:验证+余额+分组+建key+自动建渠道)\n —— 覆盖用户最主要的中转站场景\nP1:deepseek(验证+余额)、官方三家余额已在 Phase 4 完成 → 补 UsageWindow 对齐\nP2:nvidia / minimax(验证+credits/余额)\nP3:copilot / codex(验证+用量窗口提示,无余额)\n```\n\n每个来源的具体端点在实现时于 `adapter.go` 各 provider 文件内落地,本设计只固定接口契约与能力矩阵,避免文档与上游 API 变动强耦合。新增来源 = 实现 `ProviderAdapter` + 在能力矩阵登记,不改框架。\n\n**能力缺失与降级**:\n- adapter 任一能力返回 `ErrUnsupported` → 订阅中心对应字段回退为手动录入(余额手填、倍率手填),不报错、不阻塞接入。\n- adapter 验证失败/超时 → 订阅可仍以「手动模式」创建,仅失去自动刷新与自动建 key,其余流程(手填 baseURL+key 建渠道)不受影响。\n- 官方来源无「自动建 key」时,接入流程退化为 §8.4 标准快速添加:用户填 API key,adapter 只补验证与余额。\n- 所有自动刷新遵守 `SubscriptionAutoRefresh` 默认关闭;连续失败自动停用自动刷新并在驾驶舱「人工待办」提示。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3,\n \"providerTimePricing\": {\n \"deepseek\": {\n \"effectiveFrom\": \"2026-07-20T00:00:00+08:00\",\n \"timeZone\": \"Asia/Shanghai\",\n \"peakMultiplier\": 2,\n \"peakWindows\": [\n { \"start\": \"09:00\", \"end\": \"12:00\" },\n { \"start\": \"14:00\", \"end\": \"18:00\" }\n ]\n }\n }\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n`providerTimePricing.effectiveFrom` 使用 RFC3339;修改该值即可调整激活时间,设为空字符串则停用对应规则。\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**Phase 3A 状态(2026-07-09)**:低风险、不改真实路由语义的子集已完成并接入真实链路——`Manager.collectAll()` 新旧画像对比产出变更事件(`DetectProfileChanges`)、`ProfileChangelogStore`(环形内存 + SQLite,30 天机会性清理)、`EventHub` 内存 pub/sub、`GET /api/health-center/changelog`(历史)+ `GET /api/health-center/events`(WebSocket 实时推送)、`AutoDiscoveryRunner` 发布 `discovery_completed`/`auto_mapping_applied`、前端 `ProfileChangelogTimeline.vue` 接入健康中心页并支持断线自动重连。均为只读展示,未修改 `SmartRouter`/`EndpointAttemptPolicy` 调度逻辑。落地时顺带发现并修复一个真实缺口:浏览器原生 WebSocket 无法设自定义 header,为此给 `middleware.getAPIKey` 增加了 `Sec-WebSocket-Protocol` 鉴权回退(业界标准做法,仅新增一个 key 来源,不降低现有校验)。\n\n**Phase 3B(已全部完成,2026-07-09)**:模型自动映射、自动恢复探测、晋升/降级机制会直接改变真实调度决策,风险明显高于 3A,四个子项已分批全部完成。§12.2 P1.5 的 SQLite schema version / 版本化 migration / `OriginType`&`OriginTier` 旧配置 backfill / `killSwitch`+按 task class/channel disable **已于 2026-07-09 补强完成**(详见 §12.2 P1.5)。**Phase 3B-1(自动恢复探测补全)已于 2026-07-09 完成**:修复了 L2 探测子系统里此前从未打通的两个真实 bug——① `collectAll()` 每轮把新构造的画像整行 Upsert,会把 `ProbeWorker` 刚写入的 `LastProbeAt`/`ProbeSuccess`/`ConsecutiveProbeSuccess` 等字段清零,导致 6 小时探测冷却期形同虚设(修复:`carryForwardProbeFields` 在 Upsert 前搬运 Probe* 字段,不影响 L1 诊断字段的现有覆盖逻辑);② `extractRawAPIKey` 是返回 `KeyMask` 掩码值的占位 stub,探测请求实际带的是假 key(修复:新增 `APIKeyResolver` 回调 + `Manager.ResolveAPIKey`,通过遍历配置里对应渠道的 `APIKeys` 逐个计算 `KeyHashFromAPIKey` 比对来还原明文;resolver 未注入或未命中时 fail-open 跳过探测且不消耗预算)。同时补齐了 `degraded/limited → healthy` 的连续成功恢复路径(新增 `KeyEndpointProfile.ConsecutiveProbeSuccess` 计数器 + `HealthCheckConfig.ProbeRecoveryThreshold`,默认连续 2 次探测成功才恢复,避免单次探测噪声导致状态抖动;探测失败计数器清零)。**Phase 3B-3(StabilityTier 晋降级滞后)已于 2026-07-09 完成**(提交 664d3fff):在原本每轮从瞬时 1h 窗口重算的无记忆 `StabilityTier` 上加一层连续窗口滞后——新增 `KeyEndpointProfile.EffectiveStabilityTier`/`StabilityPendingTier`/`StabilityPendingStreak` 三字段 + 纯函数 `applyStabilityHysteresis`(对称阈值:连续 N 轮同值才切换生效档,`HealthCheckConfig.StabilityHysteresisWindows` 默认 2;首次画像直接采用,不等待阈值,避免新渠道卡在零值),`AggregateChannelProfile` 改读 `EffectiveStabilityTier`(空则回退 `StabilityTier`)。晋升/降级机制的数据模型仅限于 StabilityTier,不改变 QualityTier/SpeedTier/CostTier 语义。**Phase 3B-2(模型自动映射闭环)已于 2026-07-09 完成**(提交 0f02e988,随后修复一处 critical 集成 bug,提交 856372c4):新建 `ModelProfileStore`(SQLite,`model_profiles` 表)+ 扩展 `AutoDiscoveryRunner` 为每个发现模型写入 `ModelProfile`(仅 `AutoManaged` 渠道);新建 `ModelResolver` 实现 §5.4 五步逻辑(手动 `RedirectModel` 短路 → `GetModelProfiles` → `filterByCapabilityFloor` → `rankBySimilarity`);`scheduler` 包新增 `ModelSupportResolverFunc` hook(fail-open,未注入或返回不支持时严格回退到原 `ExplainModelSupport`);`endpoint_policy.go` 的 `resolveMappedModel`(此前是永不触发的死代码——`profile.ModelMapping` 从未被写入)现调用 `ModelResolver`;`handlers/common` 在 `RedirectModel` 之后应用 `MappedModel` 覆盖请求体,并按 `EchoMappedModel` 配置回显 `X-CCX-Mapped-Model`/`X-CCX-Original-Model`/`X-CCX-Mapping-Source` 响应头。三条件安全门控:`ModelMapping.AutoResolve==true`(**默认由死配置的 true 改为 false**)AND `RoutingMode ∈ {assist, auto}` AND 无 `KillSwitch`,三者任一不满足则所有新增代码路径行为与今天字节级一致;且仅 `AutoManaged` 渠道触发,手动配置渠道完全不受影响。对抗性复核发现并修复一个真实 critical bug:`scoreEndpointForKey` 用空 `channelUID` 计算 `EndpointUID` 并写入 `modelByUID`,而 `handlers` 层用真实 `channelUID` 查 `modelByUID`,两者哈希永不相等导致 `MappedModel` 在 key 级调度路径上完全查不到(提交 856372c4 修复:命中画像后改用 `profile.EndpointUID`,新增回归测试锁定该不变量)。至此 Phase 3B 四个子项(自动恢复探测、StabilityTier 滞后、模型自动映射、active filter 前置判定)全部完成;SLO regression 自动回滚仍是独立子系统,留待后续单独立项。\n\n**范围**:\n- [x] 运行时指标驱动画像实时更新(Phase 3A:`collectAll()` 每轮 diff + changelog + WebSocket 推送)\n- [x] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)—— Phase 3B-2(2026-07-09 完成,提交 0f02e988 + 856372c4)\n- [x] active model filter / context filter 支持自动映射前置判定 —— Phase 3B-2:`scheduler.ModelSupportResolverFunc` fail-open 接入 `select.go`\n- [x] 自动恢复探测(limited/dead → healthy)—— Phase 3B-1(2026-07-09 完成):`APIKeyResolver` + `carryForwardProbeFields` + `ConsecutiveProbeSuccess`/`ProbeRecoveryThreshold`\n- [x] 晋升/降级机制(连续成功→升级,连续失败→降级)—— Phase 3B-3(2026-07-09 完成,提交 664d3fff):`StabilityTier` 连续窗口滞后(`EffectiveStabilityTier`),暂不含 QualityTier/SpeedTier/CostTier\n- [x] WebSocket 推送画像变更事件(Phase 3A:`GET /api/health-center/events`)\n- [x] 前端画像变更历史/时间线(Phase 3A:`ProfileChangelogTimeline.vue`)\n\n**预估工期**:2-3 周(3A、3B-1、3B-2、3B-3 均已完成;advisor 与全局 auto 的 SLO 回滚均已补齐)\n\n> 状态校正(2026-07-17):上方 Phase 3B 历史说明中的“SLO regression 自动回滚待单独立项”已完成;advisor 回滚与全局 auto 上线闸门/自动降级是两个独立保护层。\n\n### Phase 4:高级特性\n\n**Phase 4 状态(2026-07-09,全部完成)**:8 项全部实现并接入真实链路,均遵循\"默认关闭 + 显式 opt-in\"安全守则。\n\n**范围**:\n- [x] 多维度标签系统扩展(用户自定义标签)——Item 1\n- [x] 成本报表:按用户/模型/渠道/key 统计真实有效成本——Item 2(新增 `proxyKeyMask` 身份贯穿链路:`middleware.GetProxyKeyMask` → `ChannelLog`/`RequestRecord` → SQLite `proxy_key_mask` 列,供 Item 4 复用)\n- [x] Trusted advisor SLO regression 自动回滚——Item 3(默认关闭;`SLORollbackConfig`,连续 degrading 窗口数默认 3,不做自动恢复以避免震荡)\n- [x] 渠道推荐:根据使用模式推荐新渠道——Item 4\n- [x] 批量渠道管理(导入/导出/模板)——Item 5\n- [x] 订阅中心 provider adapter:可选自动刷新余额/套餐状态——Item 6(默认关闭;仅 openai/anthropic/google 且显式填写 BillingAPIKey 的订阅生效,中转/公益渠道继续手动维护)\n- [x] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板——Item 7\n- [x] A/B 测试:低比例统计抽样双发——Item 8(默认关闭,默认 1% 抽样 + 每小时 60 次硬预算;主请求路径不受影响,影子请求在主响应返回后异步发起)\n\n**关键实现说明**:\n- Item 3/6/8 均在 `AutopilotRoutingConfig` 新增独立配置块(`SLORollback`/`SubscriptionAutoRefresh`/`ABTest`),`Validate()` 统一做负值/越界兜底,保持与既有字段一致的防御模式。\n- Item 4 直接复用 Item 2 的 `proxyKeyMask` 身份标记,未重新发明身份识别机制。\n- **全局 auto 安全闸门(2026-07-17)**:请求终态按 15 分钟窗口无偏聚合,详细 trace 继续抽样;管理 API 只在 shadow/assist 已积累至少 500 个有效请求、24 小时连续观测且成功率/fallback/fail-open/p95 均达标时允许切换 `auto`。后台连续检测三个已完成 auto 窗口,相对最近七天安全模式基线持续恶化时,持久化降级到 `assist` 并记录 `autopilot_auto_safety_events`,不自动恢复。\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- ~~SQLite schema version 和幂等 migration。~~ **已完成**:`ensureSchemaVersion` 基于 `PRAGMA user_version`,当前 7 张表定版为 v1 基线;库版本高于代码版本时返回 error(fail-closed,不做任何写操作),交给下游 fail-open 分支接管。\n- ~~旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。~~ **已完成**:`ensureOriginBackfill` 只补 `\"unknown\"`,不做任何基于 URL/名称的猜测推断;已接线到 `Profiler.DeriveEndpointProfile`,`BreakTieByOriginTier` 现在能吃到真实数据(此前一直是 fallback 的 `unknown`)。\n- ~~profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。~~ **已确认满足并测试锁定**:`main.go` 现有结构本就是 fail-open(`NewProfileStore`/`NewManager` 出错只打日志、`autopilotManager` 保持 `nil`,下游全部有 `nil` 守卫),本次新增 `schema_migration_test.go` 用测试锁定这条不变量。\n- ~~全局 `killSwitch`、按 task class disable、按 channel disable。~~ **已完成**:新增 `AutopilotRoutingConfig.DisabledTaskClasses`/`DisabledChannelUIDs`;`CandidateFilterFor`/`executeFilter`/`BuildPlan` 三处一致接入,`smart_router_invariant_test.go` 覆盖\"task class 禁用后回退默认调度\"\"channel 禁用后不出现在候选里\"\"dry-run 与真实路径行为一致\"三条不变量。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。**部分完成**:`ReleaseController.EvaluateAndApplyRegression` 已实现三窗口回归检测和 `SafetyOverride` 降级机制;`RoutingModeActive` 已加入配置枚举和 `IsAutopilotActive`。\n\n#### P1.6 Trace v2 契约与灰度发布\n\n> 对应实施计划:`docs/superpowers/plans/2026-07-23-autopilot-trace-rollout.md`\n\n**Trace v2 核心变更:**\n\n- `RoutingDecisionTrace` 增加 `SchemaVersion`、`TraceRevision`、`RequestCorrelationId`、`ReleaseID`、`PolicyFingerprint`、`TargetMode`/`EffectiveMode`/`Cohort`、`SchedulerDecision`、`EndpointAttempts` 字段。\n- 新增 `TraceDetailV2`、`TraceSummary`、`SchedulerDecisionSummary`、`EndpointAttemptSummary` DTO;使用显式 DTO 落盘而非直接 JSON 化运行时结构。\n- `ComparisonStatus` 三态枚举(`matched`/`mismatched`/`uncompared`)替代裸 `bool`。\n- `SanitizeForPersistence` 与 `SanitizeForResponse` 双边界脱敏;`GenerateTraceUIDv2` 使用 `crypto/rand` 碰撞安全生成。\n- `RoutingReleaseSnapshot` 不可变发布快照,入口冻结后续只读。\n\n**SQLite v6 迁移:**\n\n- trace 表新增列:`schema_version`、`trace_revision`、`request_correlation_id`、`release_id`、`policy_fingerprint`、`persistence_class`、`details_json`。\n- 窗口表重建主键:增加 `release_id`/`policy_fingerprint`/`cohort` 维度,新增 `compared_count`/`matched_count`/`mismatch_count`/`uncompared_count` 全量比较计数。\n- safety event 表增加 `release_id`/`policy_fingerprint` 字段。\n\n**Autopilot 单一运行态(2026-07-28):**\n\n顶层 `off → shadow → assist → auto → active` 灰度状态机已经废止。系统启动后固定执行 Autopilot 自动路由:应用硬约束过滤并按评分重排,候选全部被过滤时 fail-open。旧配置中的 `mode` 值会在加载时统一归一化为 `auto`,不再提供模式切换 API 或管理界面。\n\n`AUTOPILOT_KILL_SWITCH` 与配置中的 `killSwitch` 是唯一的全局急停入口;启用后不注入 SmartRouter 和 EndpointAttemptPolicy,恢复调度器默认行为。管理端 dry-run 仍作为独立诊断能力保留,不属于运行模式。\n\n发布批次、策略指纹和 cohort 字段继续保留在 Trace 契约中,用于兼容历史数据及诊断聚合;`ReleaseController`、readiness 样本门槛、自动降级/恢复和灰度模式切换代码均已删除。\n\n**只读 Trace API:**\n\n| 接口 | 返回 | 约束 |\n|---|---|---|\n| `GET /api/autopilot/traces` | `TraceSummary[]` | 游标分页、release/cohort/mode 过滤、partial 标记 |\n| `GET /api/autopilot/traces/:traceUid` | `TraceDetailV2` | 404=未找到/已过期/未采样;503=存储不可用 |\n| `GET /api/autopilot/traces/stats` | `TraceStatsResponse` | 三态比较统计、Mode/TaskClass 分布 |\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | advisor/local-model 保留独立 shadow 门槛;全局 Kill Switch 可立即停止 Autopilot |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "08fd5c7e0f333b1437e080fe32cfc2115be9ff12": {"t": 1785759326.0396993, "n": 167479, "h": "245ceceb056f0f83", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.2.3 同订阅多 Key:能力共享与配额负载均衡\n\n**问题**:官方订阅 plan(Claude Max、Codex plan 等)允许用户持有多个 key,或购买多份相同套餐。若每个 endpoint 独立探测与配置,探测预算被浪费、配置重复;请求量大时还需要在同 plan 的多个 key 之间按官方 RPM 限速自动负载均衡,而不是打满第一个 key 才溢出到下一个。\n\n**能力检测去重(同一套餐只需一套能力检测和配置)**:\n\n```text\nSubscriptionUID 相同的 endpoint 共享一份「订阅级能力画像」:\n 模型列表 / vision / tool / reasoning 支持 / 协议兼容开关 → 只探测一次,\n 存在 SubscriptionProfile.SharedCapability,endpoint 画像以引用方式继承。\n\n仍然 per-endpoint 维护的:健康状态、延迟、熔断、用量窗口\n (key 可能被单独封禁/限速,能力相同不代表运行态相同)\n\n继承失效条件:某 key 实测模型列表哈希与订阅级不一致(GroupChangeDetector 信号)\n → 该 key 脱离继承、独立探测,并在 UI 标记「与套餐能力不一致」警告。\n```\n\n**配额负载均衡(Phase 1 shadow 展示,Phase 2 生效)**:\n\n```text\n每 key 维护配额余量视图:\n remainingRPM = officialRPM − 当前窗口已用\n officialRPM 来源优先级:内置 manifest > 用户配置 > RateLimitDiscoverer 建议\n\n同订阅多 key 时按「剩余余量加权」分配请求(余量大者优先、\n窗口最早重置者兜底),替代顺序打满:\n 仅当渠道 autoManaged 且同一 SubscriptionUID 下 key 数 > 1 时启用;\n 否则保持现有 sequential/random key 顺序策略不变。\n\n某 key 的窗口配额耗尽 → 临时降权并切到同订阅其他 key,\n不熔断整个渠道(这是配额耗尽,不是故障)。\n```\n\n### 3.2.4 订阅用量窗口 (Usage Windows)\n\n部分官方 plan 提供 5h / 日 / 周 / 月等窗口的用量查询与限制(如 Claude 订阅的 5h 滚动窗口 + 周配额)。在 key 的位置展示这些窗口,帮助用户掌握使用情况:\n\n```go\ntype UsageWindow struct {\n Window string `json:\"window\"` // \"5h\" | \"day\" | \"week\" | \"month\"\n Used float64 `json:\"used\"` // 已用量\n Limit float64 `json:\"limit\"` // 上限,0 = 未知\n Unit string `json:\"unit\"` // requests | tokens | credits | percent\n ResetsAt time.Time `json:\"resetsAt\"` // 窗口重置时间\n Source string `json:\"source\"` // official_api | response_header | local_metering\n FetchedAt time.Time `json:\"fetchedAt\"`\n}\n// KeyEndpointProfile 与 SubscriptionProfile 均增加 UsageWindows []UsageWindow\n```\n\n数据来源优先级:\n\n```text\n1. 官方用量 API:有 provider adapter 的 plan 主动拉取(§Phase 4 订阅 adapter 的可提前子集)\n2. 响应头:anthropic-ratelimit-* / x-ratelimit-* 等(复用 RateLimitDiscoverer 的信号 tap)\n3. 本地计量兜底:CCX 按 key 累计请求数/token(误差来自窗口起点对齐,标注 Source=local_metering)\n```\n\nUI 要求:key 行内显示主窗口(5h)用量进度条,悬浮展开全部窗口;用量 >80% 黄色预警、超限红色并显示预计重置时间。调度联动(Phase 2):窗口余量进入配额负载均衡权重,窗口耗尽的 key 临时降权而非熔断。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n#### 4.2.1 内置模型清单 (Builtin Models Manifest)\n\n**问题**:部分官方订阅 plan 的入口没有正确的 models 接口——要么不存在(404)、要么需要额外权限、要么返回与订阅实际可用模型不符的列表(典型:Claude 订阅 OAuth 入口、Codex plan 入口、部分官方企业网关)。对这些 baseURL 做模型发现会失败或产出错误画像。\n\n**方案**:CCX 内置「已知官方 baseURL → 模型清单」映射表,命中时直接返回内置内容,完全跳过上游 models 接口:\n\n```go\n// backend-go/internal/config/builtin_models_manifest.go(生成式,随模型注册表更新)\ntype BuiltinModelsManifest struct {\n BaseURLPattern string `json:\"baseUrlPattern\"` // 精确 host 或前缀匹配,如 \"api.anthropic.com\"\n ServiceType string `json:\"serviceType\"` // messages/responses/…\n PlanHint string `json:\"planHint,omitempty\"` // 订阅类型提示(如 \"claude_subscription\")\n ModelIDs []string `json:\"modelIds\"` // 该入口实际可用的模型清单\n DisableProbe bool `json:\"disableProbe\"` // true 时 Discovery 跳过 GET /v1/models\n}\n```\n\n**两个生效位置**:\n\n```text\n1. Discovery(§4.2 流程第 2 步之前):\n baseURL 命中 manifest 且 DisableProbe=true\n → 跳过 GET /v1/models,直接用 ModelIDs 生成 ModelProfile[]\n → ProfileSource = \"builtin_manifest\",不再因 models 接口失败而误判 misconfigured\n\n2. /v1/models 代理响应(现有 Models API):\n 客户端向 CCX 请求 /v1/models 时,聚合结果中该渠道的贡献\n 直接取内置清单,不向上游发起 models 请求\n (渠道显式配置了 supportedModels 时仍以用户配置优先)\n```\n\n**维护与覆盖顺序**:\n\n```text\n渠道显式 supportedModels(用户手动) > 内置 manifest > 上游 models 接口实探 > 内置候选模型回退\n```\n\nmanifest 与模型注册表同源生成(`generated_model_registry.go` 同级),新模型发布时随注册表一起更新;HealthAnalyzer 对命中 manifest 的 endpoint 不将「models 接口 404/403」计入 misconfigured 信号。\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n MappedEffort EffortLevel // 自动映射的 effort 级别(空=未决定)\n MappedEffortDecided bool // effort 是否由 Autopilot 决定\n EndpointUID string // 稳定 endpoint ID\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n\n // ResolvedModelByEndpointUID(旧 API):仅返回模型名,向后兼容。\n // ResolvedTargetByEndpointUID(新 API):返回 model + effort 原子决策。\n // 两者从同一份 targetByUID 派生,避免两个真值源。\n ResolvedModelByEndpointUID func(endpointUID string) string\n ResolvedTargetByEndpointUID func(endpointUID string) *ResolvedRouteTarget\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20;assist 下 dead/misconfigured 额外 -100 并保留在队尾\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法细分时进入 `general`;有规范 Knowledge 基准则使用其软评分,缺少直接证据时仍取 0.5 中性值。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n**手动 L3 第一版状态(2026-07-15)**:已实现 `pq-v1-20260715` 固定 JSON canary,覆盖\nMessages、Chat Completions、Responses 与 Gemini 上游协议。调用方只能指定 `endpointUid`、\n`modelId` 和 1~3 次重复数,不能注入 prompt。评分仍按 40% 完整性、30% 语义正确性、\n15% 严格 JSON 格式、15% 延迟执行;第一版使用非流式完整响应延迟作为首 token 延迟代理,\n后续引入流式采样时再替换该维度。单次有效样本 confidence=0.6,最多三次提升到 0.8;\n失败样本按 0 分计入均值,全部失败不写画像。\n\n安全边界:仅管理 API 手动触发,不启动自动 worker;当前进程按 UTC 日设置 12 次硬预算,\n请求前原子预留,单次 90 秒超时、响应体上限 64 KiB。同一 endpoint×model 不并发执行;\n单次请求最多允许 2048 个输出 token,以容纳推理模型的隐藏思考并限制最坏额度消耗。\n对声明可控推理的模型固定采用最低可用 effort;Claude 兼容端点若只暴露\n`reasoning_content` 而未声明 thinking 模式,则与 capability-test 一致显式关闭 thinking。\n原始模型输出和明文 Key 既不落盘也不返回,只保存分数、canary 版本、置信度和脱敏判定。\n`user_feedback` 仍高于 `probe`,自动发现重建 ModelProfile 时必须保留既有质量证据。\n跨进程共享预算与 LLM-as-judge/语义向量评分仍属于后续阶段。\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n\n乘数是预设内建的常量,不对外暴露;非法或未知 mode 归一化回退 `balanced`。\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法细分的通用任务;缺少基准证据时中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general;有规范 Knowledge 基准时使用其软评分,缺少直接证据时取 0.5 中性。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? canonicalBenchmark[model][domain] * providerQualityFactor\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 规范模型基准 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n#### 5.7.4 规范模型能力基准\n\n`shared/model-registry/ccx_model_registry.json` 的 `benchmarkProfiles` 独立保存规范模型能力上界,不与 `upstreamCapabilities` 混合。后者仍是 context、vision、tool calls 等硬能力的事实源;benchmark 只参与 Autopilot 软评分和 trace 解释,绝不改变 `supportedModels` 或 CapabilityFloor。\n\n```text\nEffectiveCapability[domain]\n = CanonicalCeiling[model, domain]\n × ProviderQualityFactor[endpoint]\n\nProviderQualityFactor = 1 - confidence × (1 - providerQualityScore)\n无渠道质量证据或 confidence<0.5 时 factor=1;可信证据只能从规范模型上界向下折算。\n```\n\n基准数据必须保留领域向量、来源、核验日期、lane、共享结果数和可比类别覆盖率,不能只保存站点 Overall。当前 BenchLM 接入为 `provisional`:Claude Opus 4.8、GPT-5.6 Terra、GPT-5.6 Sol 各有 30 个共享结果,8 类中 5 类可比,核验日期为 2026-07-13。\n\n领域映射:Knowledge → `general`,Math → `reasoning`,Coding → `coding` / `code_review`,Agentic → `agentic`,Multimodal → `aesthetics_ui`(仅弱代理,解释置信度减半)。Writing / Translation 没有直接类别证据时继续回退家族种子或 0.5。BenchLM 的 cohort 归一化、缺失项插补和外部校准决定了 Overall 不是绝对能力值,因此路由只消费可映射的原始类别分。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n#### 5.8.4 联合决策的现状实现(model + effort 原子化)\n\n> 状态说明(2026-07-26):本节描述的联合决策链路已实现并接入真实请求路径(关联计划 [2026-07-26-autopilot-model-effort-joint-routing.md](../superpowers/plans/2026-07-26-autopilot-model-effort-joint-routing.md) 的 Task 1~7)。**渠道级手工 `modelMapping` / `reasoningMapping` 表目前仍然存在且仍然生效**,退役是该计划的 Task 8,尚未执行;手工表当前的优先级关系见下文。\n\n**决策对象。** 自动路由的返回值不再是裸模型名,而是 `autopilot.ResolvedRouteTarget`(`backend-go/internal/autopilot/route_target.go`):\n\n```go\ntype ResolvedRouteTarget struct {\n Model string // 目标模型 ID\n Effort EffortLevel // 目标思考档位;空串表示不改写\n EffortDecided bool // true 表示 effort 由 Autopilot 决定;false 为 passthrough\n Reason string // 决策原因,用于 Trace 与日志\n}\n```\n\n`EffortDecided=false` 且 `Effort` 为空是合法的 fail-open 结果:模型可能被改写,但思考参数保持原样。\n\n**决策发生的位置。** `autopilot/endpoint_policy.go` 的 `resolveMappedModel()` 是统一入口,内部分两路:`resolveManualMapping()` 处理渠道级显式 `ModelMapping`(仍是当前优先级更高的一路,返回的 `ResolvedRouteTarget.EffortDecided` 恒为 `false`,即手工映射只改模型不改档位);`resolveAutoModel()` 调用 `ModelResolver` 做自动模型解析,并在 `model_resolver.go` 的 `rankEligibleModels()` 中按 `SupportedEffortLevels` × `PerTaskClass` 展开 `(model, effort)` 候选、应用 `EffortQualityBonus` 与 anti-effort-inflation 反膨胀 tiebreak(同质量档优先选低 effort)、并按 `EffortFloor` 过滤掉低于任务下界的候选。产出的 `ResolvedRouteTarget` 经 `buildResolvedTargetLookup()` 挂到 `EndpointAttemptPolicy.ResolvedTargetByEndpointUID`,与旧的 `ResolvedModelByEndpointUID`(仅模型,向后兼容)并列。\n\n**决策如何到达上游请求。** `handlers/common/upstream_failover.go` 的 `TryUpstreamWithAllKeys` 在为每个 endpoint 尝试构建请求前,通过 `ResolvedTargetByEndpointUID` 取得 `*ResolvedRouteTarget`,交给 `atomicModelEffortRewrite()` 做一次原子改写:先用 `sjson` 改写 `model` 字段;若 `EffortDecided=true` 且 `Effort` 非空,再检查目标模型的 `ThinkingMode`——`adaptive_only` 的模型跳过思考参数注入(模型自身决定思考深度,守卫逻辑对齐 `providers/claude.go` 的 `applyClaudeReasoningEffort`),否则调用 `config.ApplyReasoningParamStyle()` 写入。模型改写与档位改写共用一次函数调用,任一步 `sjson`/`json` 处理失败即整体放弃、保持原始 body,不会出现\"模型改了、档位没改\"的中间态。\n\n**各协议的注入形态。** `config.ApplyReasoningParamStyle(req, style, effort)`(`config/config_utils.go`)按 `upstream.ReasoningParamStyle` 三态分支:\n\n```text\nstyle=\"thinking\" → Claude 派系:写 thinking.type=enabled + thinking.effort=;\n effort 为 off/none 时写 thinking.type=disabled;同时清理互斥字段\n reasoning / reasoning_effort / thinking.budget_tokens\nstyle=\"reasoning_effort\" → OpenAI Chat 派系:写顶层 reasoning_effort 字段,清理 reasoning\nstyle=\"\" / 默认 → Responses 派系:写 reasoning.effort 对象形态\n```\n\n**Gemini 派系是尚未接通的缺口。** 请求侧提取(`extractGeminiEffortExplicit()`,`handlers/common/reasoning_log.go`)已经能读取 `generationConfig.thinkingConfig.thinkingLevel` 并识别显式 `none`(`thinkingBudget=0`),这条读路径用于日志与 `RequestProfile.ClientEffort` 提取。但写路径——把联合决策的 `target.Effort` 写回 Gemini 请求体的 `thinkingConfig.thinkingLevel`——目前代码中不存在:`atomicModelEffortRewrite()` 唯一的写出口是 `ApplyReasoningParamStyle()`,它只覆盖 Claude/Chat/Responses 三种形态,没有 Gemini 分支;`handlers/gemini/handler.go` 的 `buildProviderRequest()` 也没有调用任何 effort 注入函数。也就是说,Gemini 渠道当前仍会经过 endpoint 级模型改写(`atomicModelEffortRewrite` 会改写 `model` 字段本身),但联合决策选中的思考档位不会落到 Gemini 请求体上,效果等同于 fail-open passthrough。此项在原计划里归属 Task 5 的\"Gemini 派系注入路径\",尚未实现。\n\n**可观测性字段。** 原子改写点会向 `gin.Context` 写入两个字段供 ChannelLog 使用:`effortDecisionSource`(`autopilot` 表示由联合决策产出,`passthrough` 表示未改写或手工映射路径)、`effortClampedByClient`(当客户端显式声明的档位低于 Autopilot 选择的档位时置 `true`,由 `isEffortClampedByClient()` 基于 `EffortLevelOrdinal` 比较得出)。\n\n**手工映射表的现状。** `resolveMappedModel()` 当前的分支顺序是\"显式 `ModelMapping` 优先,自动决策兜底\"——与原计划 Task 7 描述的\"反转优先级\"目标相反的方向仍是默认值;反转开关已作为可配置项落地(提交 `d15e776e`),但默认状态未切换,`ModelMapping` / `ReasoningMapping` 两张表本身尚未删除,`config.ResolveReasoningEffort()`(`config/config_utils.go`)与 provider 层(如 `providers/claude.go`)仍会读取 `ReasoningMapping` 独立生效。表的整体退役是 Task 8 的范围,未开始。\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**请求意图边界(ModelRoutingPolicy)**\n\n跨模型替代不是所有模型的默认能力,只对白名单客户端入口开放:\n\n```text\nmessages:\n fable / opus / sonnet / haiku 及对应 claude-* 完整模型名 → adaptive\nresponses:\n gpt-5.6* / gpt-5.5 / gpt-5.4* / codex-auto-review → adaptive\n其他协议或模型:\n exact-only,只能在探测到相同规范化模型 ID 的 endpoint 之间调度\n```\n\n`adaptive` 允许在满足 Capability Floor 后选择 GLM、MiMo 等其他模型族;`exact-only` 禁止跨模型替代。用户显式配置的 `modelMapping` 仍优先于该策略。调度预览和 endpoint 实际解析必须调用同一分类函数,避免诊断结果与真实请求分叉。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ModelProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选优\n // 上下文窗口只负责准入,不对更大的窗口扣分。\n // 排序优先级:\n // a. 模型质量档越高越优先\n // b. provider 已确认的同档模型能力顺序\n // c. 带置信度折算的供应商实测质量\n // d. 探测延迟\n // e. 同渠道/provider 的模型相对消耗倍率\n // 倍率相同或缺失时再比较公开模型成本\n // f. 同模型族与 model ID 仅作为确定性兜底\n best := rankEligibleModels(eligible, requestModel)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n同渠道套餐倍率与公开 USD/Token 价格是不同单位,禁止直接混算。模型解析器先比较 provider\n提供的相对消耗倍率;只有双方倍率相同,或双方均无倍率时,才回退到统一输入/输出规模下的\n公开价格。优云智算当前内置倍率如下(数字越小越省,`glm-5.2` 的 2 次为限时值):\n\n| 模型 | 每次调用套餐消耗 |\n|------|-----------------:|\n| `glm-5.2` | 2 |\n| `glm-5.1` | 6 |\n| `MiniMax-M2.7` | 2 |\n| `kimi-k2.6` | 5 |\n| `deepseek-ai/DeepSeek-V3.2` | 1 |\n| `deepseek-v4-flash` | 1 |\n\nprovider 可以额外声明同一质量档内已确认的模型能力顺序,但只有该档全部候选都命中该元数据\n时才比较,避免把未知模型误判为弱模型,也保证排序不依赖候选输入顺序。优云模板当前记录\n`glm-5.1 > kimi-k2.6`,因此前者即使消耗 6 次,也会在后者的 5 次成本证据之前胜出。\n`glm-5.2` 即使消耗 2 次,也仍会在质量档高于 DeepSeek 候选时优先;V3.2 与 V4 Flash\n同为 1 次时,再由公开价格及最终确定性规则区分。静态 `modelMapping` 仅保留为用户显式覆盖,\n不承担自动替代策略。\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - autoManaged 的空 supportedModels 不表示支持全部,必须由 endpoint ModelProfile 裁决\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n当 ModelResolver 已正常初始化且路由处于 `assist/auto + autoResolve=true` 时,autoManaged 渠道的画像拒绝是权威结果,调度器不得再 fail-open 到空 `supportedModels` 的兼容语义。Resolver 不可用、开关关闭或 `off/shadow` 时仍保留原有 fail-open,保证急停和降级路径不依赖画像库。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n### 6.9 渠道兼容性统一自学习\n\n渠道级兼容性配置不应要求用户先踩坑、再去翻设置面板找开关。这一节描述如何把这些开关从\n「静态布尔」转为「运行时学习到的能力事实」。\n\n**优先级合成**(`config.resolveCompatSwitch`)\n\n```text\n最终生效值 = 学习结论 ?? 历史种子(14 天内) ?? 静态默认\n```\n\n六个兼容性字段(`NormalizeNonstandardChatRoles`、`StripImageGenerationTool`、\n`CodexNativeToolPassthrough`、`StripEmptyTextBlocks`、`PassbackReasoningContent`、\n`PassbackThinkingBlocks`)已从 `UpstreamConfig`/`UpstreamUpdate` 整体删除:管理面板不提供编辑\n入口,渠道更新接口不接受手工写入,完全交由运行时自动决策。不再有「用户当前显式设置」这一档——\n历史手工值不代表可信的长期事实,可能当初就设错,也可能上游后来有了新情况已不再适用,因此不作为\n可以无限期压过学习的强制覆盖,只作为学习结论缺失时的一次性低置信度提示。\n\n学习结论通过 `UpstreamConfig.LearnedCompatTraits`(运行时字段,`json:\"-\"`,不落盘)传递:\nfailover 在发送前按 渠道-Key-模型 查询 `ChannelCompatCache`,把结论注入当次请求专用的\nupstream 副本,provider 构造请求时读取。\n\n**历史手工配置降级为种子**(`migrateManualCompatSwitchesToSeeds`):老用户配置文件里的六个字段\n已从结构体删除,只能在启动时从磁盘原始 JSON 里读到历史手工值。迁移把这些值降级为\n`UpstreamConfig.CompatSeeds`(`map[string]CompatSeedEntry`,带 `ExpiresAt`):\n\n- 种子带 **14 天有效期**(`CompatSeedTTL`),过期后自动失效、不再参与判断,回落学习结论或静态\n 默认重新判断。种子与学习记忆分开存储且 TTL 不同(种子 14 天 vs 学习记忆 24 小时),因为二者\n 语义不同:种子是\"曾经的一个信号\",学习记忆是\"渠道-Key-模型级的当前能力事实\"。\n- 迁移保留 `false` 值:用户「显式关闭」同样是他曾观察到的行为证据。\n- 迁移当下不改变可感知行为(学习缺失时按种子生效),但学到真实结论后立即让位,种子过期后也\n 自动退场。\n- 幂等:已有种子的渠道跳过。\n- 自动托管渠道在 `RuntimeUpstreamForAutoManagedProvider` 里连种子一起清空,其兼容性完全由\n 厂商原生默认值加运行时学习决定。已知厂商协议事实(如 GLM 的 OpenAI 兼容协议要求\n `passbackReasoningContent`)不走种子这条给\"可能出错的历史手工值\"用的临时通道,而是在对应\n accessor 里按 `ProviderID`/`ServiceType` 直接计算为静态默认(`shouldPassbackReasoningContentByDefault`\n 之类),因为这是不会过期的协议事实而非猜测。\n\n**记忆载体**:`config.ChannelCompatCache`,落盘 `.config/channel_compat.json`,\n键 `channelUID:keyHash:model`,TTL 24h(与 `DeprecatedParamCache`/`SystemHeaderFilterCache` 一致)。\n`Record` 仅在结论新增或翻转时返回 `true`,调用方据此决定是否重试,防止记忆生效后死循环。\n\n**三条学习路径**(按上游信号强度区分,不可一概而论)\n\n| 信号类型 | 兼容项 | 学习方式 |\n| --- | --- | --- |\n| 硬 400/422 + 可正则匹配 | developer role 降级、Codex 专属工具剥离、历史 thinking 回传、图片工具剥离 | 错误驱动:`CompatTraitFromError` 识别 → 记忆 → 同 Key 立即重试 |\n| 无报错,仅响应形态差异 | `passbackReasoningContent`、`stripEmptyTextBlocks` | 探针驱动:首次遇到该组合时 singleflight 后台探测一次 |\n| 厂商文档已公布约束 | Kimi 固定值参数、`tool_choice`、`thinking` | 前置规避:`ApplyKnownParamConstraints` 发送前直接改写,无需失败一次 |\n\n**分层职责**:`upstream_failover` 负责识别信号、写缓存、编排重试、把结论注入 `upstreamCopy`;\n各 provider 在构造上游请求时读取合成值并执行改写。这样 developer role 降级发生在\nResponses→Chat 转换**之后**的 `reqMap` 上,避开「failover 层拿到的是 Responses `input` 而非\nChat `messages`」这个陷阱。\n\n**防误判约束**(学错并持久化比不学更糟)\n\n- 双条件门控:正则命中之外,还要求请求侧确实携带对应结构(`BodyHasDeveloperRole` 等)\n- 单次重试上限:`Record` 返回 `false` 即不再重试\n- 不跨维度污染:结论不外溢到同渠道其他 Key/模型\n- 仅 400/422 参与学习;429/5xx/超时属容量问题,不是能力问题\n- fail-open:缓存损坏、探测失败一律退化为静态默认,不阻断代理请求\n\n**图片工具归因修正**:图片生成受限时,原实现直接拉黑 (Key,模型)。现改为先学习\n「需剥离图片工具」并重试修复请求,只有剥离后仍失败才回落拉黑——从「惩罚渠道」改为「先修复请求」。\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\nGET /api/health-center/provider-quality/budget → 查看本进程手动 L3 每日预算\nPOST /api/health-center/provider-quality/probe → 对指定 endpoint×model 执行固定质量 canary\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n#### 8.0.1 实现现状与演进方向(2026-07-09 核实)\n\n四界面在 **Web 前端(`frontend/src`)已大面积落地**,不是纯文档:\n\n| 界面 | Web 实现 | Desktop 实现 |\n|------|----------|--------------|\n| 健康中心 | ✅ `HealthCenterView.vue` + `HealthCenterStats`/`HealthChannelTable`/`HealthChannelDetail`/`ProfileChangelogTimeline`,路由 `/health` | ❌ 无 |\n| 订阅中心 | ✅ `SubscriptionsView.vue` + `SubscriptionPlanTable`,CRUD + 刷新余额,路由 `/subscriptions` | ⚠️ 独立旧实现 `subscriptions/SubscriptionTab.vue`(与 Web 两套代码) |\n| 渠道中心 | ✅ `ChannelOrchestration.vue`(六态 `ChannelHealthBadge`、来源 chips、`autoManaged` 图标齐备) | ❌ 未同步 |\n| 管理面板 | ⚠️ `AutopilotModePanel.vue` 仅暴露 mode/killSwitch/costPreference | ❌ 无 |\n| 驾驶舱 | ✅ `CockpitView.vue`(观测型总览:健康统计+余额汇总+渠道推荐) | ⚠️ `cockpit` tab 渲染的是会话 Dashboard,职责不同 |\n\n**与本设计意图的关系**(对齐用户「自动调度为主、废旧手动驾驶舱」的诉求):\n- **旧的「手动调度驾驶舱」在 Web 端已不存在**。`CockpitView` 已是 §8.7 定义的观测型总览,只回答健康/省钱/风险/待办四问题,不做渠道配置编辑,符合「程序自动选、人只观测+有限干预」的方向。无需再废除,只需继续收敛为纯观测。\n- **统一提交入口已存在**(§8.4 快速添加 + §8.5 订阅中心),new-api 集成(§8.5.1)在此之上把「填 baseURL+key」进一步自动化。\n\n**前端演进待办**(本设计新增/明确):\n1. **补 `ManualRoutingIntent` 前端**:这是用户要的「有限偏向性干预」,后端已有、两个前端均未实现(§8.3 试用意图入口尚缺)。\n2. **Desktop 前端对齐 Web**:健康中心/渠道自动托管/管理面板/观测型驾驶舱在 Desktop 几乎空白,需同步或复用 Web 组件,避免长期维护两套。\n3. **管理面板补字段**:暴露 `l2ProbeEnabled`、探测预算、new-api/多来源订阅 adapter 开关(§8.5.2)等,当前只有 3 个字段。\n4. **订阅中心接 §8.5.1/§8.5.2**:新增「new-api」订阅类型与 Provider Adapter 能力矩阵驱动的动态表单。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.5.1 new-api 订阅集成(统一账号接入)\n\n**目标**:用户在订阅中心手填一个 new-api(及其 fork)账号的「系统访问令牌」即可,CCX 自动用该令牌创建代理专用 key、拉取分组倍率、查询额度余额,并把「站点 baseURL + 新建 key」自动落成一个上游渠道,交给 Autopilot 探测与调度。\n\n**范围约束**:\n- 只使用 new-api 用户在「个人设置」生成的 **系统访问令牌(access_token)** 一种凭据;令牌用于 API 调用的身份验证,属敏感数据,须妥善加密存储。\n- **不做** cookie 登录 + 2FA + 导出后台渠道明文 key 的管理员迁移路径(all-api-hub 的 newApiSession 能力),本设计不涉及。\n- new-api 归入 `OriginType=relay`、`OriginTier=second`(中转站),信任等级不因自动化提升;实时质量仍由运行画像决定。\n- 认证令牌像其他订阅凭据一样由用户手动录入,CCX 不代替用户在 new-api 侧注册或登录。\n\n**认证与凭据模型**:\n\nnew-api family 的管理接口鉴权(依据 all-api-hub 传输层实现):\n\n```text\nAuthorization: Bearer # 系统访问令牌;raw 模式可去掉 Bearer 前缀\nNew-API-User: # 当前用户 ID(必带)\nUser-id: # fork 兼容回退(Veloera/voapi/Super-API 等)\nContent-Type: application/json\n```\n\n统一响应信封:`{ \"success\": bool, \"data\": T, \"message\": string }`;解析时先判 `success`,再取 `data`。\n\n**SubscriptionProfile 扩展**:\n\n在 §3.2.2 的 `SubscriptionProfile` 基础上,为可自动接入的订阅新增 provider 集成字段(沿用「默认关闭 + 显式 opt-in」守则):\n\n```go\n// SubscriptionProfile 新增字段(复用 Phase 4 Item 6 的 SubscriptionAutoRefresh 开关)\ntype SubscriptionIntegration struct {\n Kind string `json:\"kind\"` // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | ...\n BaseURL string `json:\"baseUrl\"` // new-api 站点地址(同时作为新建渠道的上游 baseURL)\n AccessToken string `json:\"-\"` // 系统访问令牌;加密存储,不出 API 响应\n UserID string `json:\"userId\"` // New-API-User 头所需\n AuthTokenMode string `json:\"authTokenMode,omitempty\"` // \"bearer\"(默认) | \"raw\"\n // 自动建 key 的模板\n ProvisionKeyName string `json:\"provisionKeyName,omitempty\"` // 默认 \"ccx-autopilot\"\n ProvisionGroup string `json:\"provisionGroup,omitempty\"` // 建 key 时指定分组,空=默认分组\n ProvisionModels []string `json:\"provisionModels,omitempty\"` // model_limits,空=不限制\n // 自动同步产物(回填,只读展示)\n ProvisionedTokenID int `json:\"provisionedTokenId,omitempty\"`\n LinkedChannelUID string `json:\"linkedChannelUid,omitempty\"`\n}\n```\n\n`AccessToken` 必须与现有敏感字段一样走加密存储,序列化到前端时脱敏(仅显示尾部若干位)。\n\n**new-api 接口清单**(本集成实际调用的子集,路径以主线 new-api 为准,字段名保持与上游一致):\n\n| 用途 | 方法 | 路径 | 请求体 | 响应关键字段 |\n|------|------|------|--------|--------------|\n| 校验令牌 + 查用户/余额 | GET | `/api/user/self` | — | `data.id`、`data.username`、`data.quota`(剩余额度)、`data.used_quota` |\n| 拉分组倍率 | GET | `/api/user/self/groups` | — | `data: { : { desc, ratio } }` |\n| 站点全部分组 | GET | `/api/group` | — | `data: string[]` |\n| 账号可用模型 | GET | `/api/user/models` | — | `data: string[]` |\n| 创建代理 key | POST | `/api/token/` | `CreateTokenRequest`(见下) | `data.key` / `success` |\n| key 列表 | GET | `/api/token/?p={p}&size={n}` | — | `data.items[]`(含 `id/key/name/status/remain_quota`) |\n| 更新 key | PUT | `/api/token/` | `{ ...token, id }` | `success` |\n| 删除 key | DELETE | `/api/token/{id}` | — | `success` |\n\n`CreateTokenRequest`:\n\n```json\n{\n \"name\": \"ccx-autopilot\",\n \"remain_quota\": 0,\n \"expired_time\": -1,\n \"unlimited_quota\": true,\n \"model_limits_enabled\": false,\n \"model_limits\": \"\",\n \"allow_ips\": \"\",\n \"group\": \"\"\n}\n```\n\n`quota` 是 new-api 内部额度单位(非美元),换算比例读 `GET /api/status` 的 `price` / `PaymentUSDRate`(无鉴权),或允许用户在订阅中手填换算率;MVP 直接以 quota 原值展示 + 站点换算率估算余额。\n\n**接入流程**(订阅中心「新增订阅 → 类型选 new-api」):\n\n```text\n1. 用户输入:站点 baseURL + 系统访问令牌(access_token) + userId\n (userId 可选自动获取:先用令牌调 /api/user/self 拿 data.id 回填)\n2. 校验:GET /api/user/self\n ├─ success=false / 401 → 令牌无效或 userId 不匹配,报错停止\n └─ 成功 → 记录 username、quota,创建 SubscriptionProfile(provider=new_api, originTier=second)\n3. 拉分组:GET /api/user/self/groups → 写入 GroupMultipliers({group: ratio})\n GET /api/user/models → 记录账号可用模型,供渠道 supportedModels 参考\n4. 建 key:POST /api/token/(按 ProvisionKeyName/Group/Models 模板)\n ├─ 已存在同名 ccx-autopilot key → 复用其 id,不重复创建(列表比对 name)\n └─ 新建成功 → 回填 ProvisionedTokenID,取 data.key(补齐 sk- 前缀)作为渠道 apiKey\n5. 写入渠道:同 baseURL+channelKind 已有渠道时并入该渠道(多 new-api 订阅与纯 key 整合为同一渠道),否则自动新建\n ├─ 合并目标匹配:BaseURL/BaseURLs 规范化(去尾部 /,忽略大小写)相等;跳过带 providerId 的渠道;多个命中优先 active\n ├─ 合并:保留已有纯 key 与配置,新 key 按字符串去重后追加,写入 quotaGroup/groupMultiplier 元数据\n ├─ 新建:channelKind 由用户选择或按站点探测(messages/chat/responses/gemini),status=unknown,autoManaged=true,OriginType=relay\n ├─ 建立 SubscriptionUID → ChannelUID 链接(回填 LinkedChannelUID)\n └─ 触发 §8.4 的 Discovery + 能力测试标准流程\n6. 完成:订阅卡片显示余额、分组倍率、已绑定渠道、探测进度\n```\n\n关键点:new-api 集成**不额外造探测逻辑**,第 5 步之后完全复用现有 §8.4「添加并探测」链路;集成层只负责「令牌 → key + 分组 + 余额 + baseURL」的自动填充,把手填 baseURL/key 的动作自动化掉。\n\n**刷新与同步**(复用 `POST /api/subscriptions/{uid}/refresh`):\n\n| 触发 | 动作 |\n|------|------|\n| 手动刷新余额 | `GET /api/user/self` 更新 `Balance` + `BalanceUpdatedAt` |\n| 定时刷新(可选,opt-in) | 复用 Phase 4 Item 6 `SubscriptionAutoRefresh`;new-api 作为 `relay` provider adapter 纳入,默认关闭 |\n| 分组倍率变更 | 刷新时对比 `/api/user/self/groups`,`GroupMultipliers` 变化 → 更新并提示「套餐倍率已变」 |\n| key 失效检测 | 渠道侧探测到该 key 全 401 → 订阅卡片提示「key 可能已删除/禁用」,提供「重新建 key」一键动作(重跑第 4-5 步,复用旧渠道,只换 apiKey) |\n| 可用模型变更 | `/api/user/models` 结果哈希变化 → 触发对应渠道重新 Discovery |\n\n**安全与边界**:\n- `access_token` 权限等同 new-api 账户,须加密存储、脱敏展示、禁止写入日志;刷新失败连续 N 次自动停用该订阅的自动刷新,只保留手动。\n- 自动建 key 默认 `unlimited_quota=true` 但可由用户改为限额模板;建 key 是有副作用的写操作,须在接入前明确告知用户「将在 new-api 侧创建一个名为 ccx-autopilot 的令牌」。\n- new-api 是 `OriginTier=second`,**不得**用作隐私敏感的 routing advisor / classifier(遵守 §3.2.1 约束)。\n- fork 兼容:请求同时带 `New-API-User` 与 `User-id` 头;响应信封字段名(`success/data/message`)在部分 fork 可能不同,adapter 需容错解析,失败时回退手动录入。\n- 一个 new-api 订阅默认只自动建/绑定一个渠道;用户如需该站多协议渠道,可在同订阅下手动增建,共用同一 key。\n\n### 8.5.2 订阅 Provider Adapter 框架(多来源余额与接入)\n\n§8.5.1 的 new-api 集成不是特例,而是「订阅 Provider Adapter」框架的一个实现。Phase 4 Item 6 已落地 openai/anthropic/google 的余额刷新 adapter,本框架把它抽象成统一接口,让 new-api 及后续来源(GitHub Copilot、Codex、Nvidia、MiniMax、DeepSeek 等)按相同契约接入,避免每加一个来源就散落一套逻辑(DRY)。\n\n**统一接口**:\n\n```go\n// backend-go/internal/autopilot/subscription/adapter.go\ntype ProviderAdapter interface {\n Kind() string // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | \"copilot\" | \"deepseek\" | ...\n\n // 校验凭据,返回账号身份(用户名/账户标识);失败即凭据无效\n Verify(ctx context.Context, cred Credential) (AccountInfo, error)\n // 查余额/额度;不支持返回 ErrUnsupported,由 UI 降级为手动录入\n FetchBalance(ctx context.Context, cred Credential) (Balance, error)\n // 拉分组倍率;仅 new-api 类中转有,官方直连返回 ErrUnsupported\n FetchGroups(ctx context.Context, cred Credential) (map[string]GroupRatio, error)\n // 自动创建代理 key;官方直连通常不支持(key 即用户 API key),返回 ErrUnsupported\n ProvisionKey(ctx context.Context, cred Credential, tmpl KeyTemplate) (ProvisionedKey, error)\n // 可用模型列表;用于渠道 supportedModels 参考\n FetchModels(ctx context.Context, cred Credential) ([]string, error)\n}\n```\n\n每个能力独立、可选:adapter 只实现自己支持的部分,不支持的统一返回 `ErrUnsupported`(接口隔离原则),UI 据此隐藏/降级对应入口。所有 adapter 共用 `SubscriptionAutoRefresh` 的 opt-in 开关与 `Validate()` 兜底,与 Phase 4 现有防御模式一致。\n\n**能力矩阵**(✅ 支持 / ⚠️ 部分或需确认 / ❌ 不支持,走手动):\n\n| 来源 | Kind | 验证 | 查余额 | 拉分组 | 自动建 key | OriginTier |\n|------|------|:----:|:------:|:------:|:----------:|------------|\n| new-api family | `new_api` | ✅ | ✅ quota | ✅ | ✅ | second |\n| OpenAI 官方 | `openai` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Anthropic 官方 | `anthropic` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Google 官方 | `google` | ✅ | ⚠️ | ❌ | ❌ | first |\n| GitHub Copilot | `copilot` | ⚠️ token | ❌ 无额度概念 | ❌ | ❌ | first |\n| Codex plan | `codex` | ⚠️ | ⚠️ 用量窗口 | ❌ | ❌ | first |\n| Nvidia NIM | `nvidia` | ✅ | ⚠️ credits | ❌ | ❌ | first |\n| MiniMax | `minimax` | ✅ | ⚠️ | ❌ | ❌ | first |\n| DeepSeek | `deepseek` | ✅ | ✅ balance API | ❌ | ❌ | first |\n\n说明:官方直连来源普遍**不支持自动建 key**(用户的 API key 本身就是凭据,直接作为渠道 apiKey 录入即可)、也**没有分组倍率**(价格按官方定价表)。它们的 adapter 价值集中在「验证 + 查余额/用量窗口」,把 §3.2.4 UsageWindow 的官方用量 API 来源坐实。DeepSeek 有独立余额查询端点,可完整支持余额。Copilot 属订阅制、无 token 余额概念,只做验证 + 用量提示。\n\n**接入分层**(YAGNI:不强求一次做全,按价值排期):\n\n```text\nP0(本轮):new_api(完整:验证+余额+分组+建key+自动建渠道)\n —— 覆盖用户最主要的中转站场景\nP1:deepseek(验证+余额)、官方三家余额已在 Phase 4 完成 → 补 UsageWindow 对齐\nP2:nvidia / minimax(验证+credits/余额)\nP3:copilot / codex(验证+用量窗口提示,无余额)\n```\n\n每个来源的具体端点在实现时于 `adapter.go` 各 provider 文件内落地,本设计只固定接口契约与能力矩阵,避免文档与上游 API 变动强耦合。新增来源 = 实现 `ProviderAdapter` + 在能力矩阵登记,不改框架。\n\n**能力缺失与降级**:\n- adapter 任一能力返回 `ErrUnsupported` → 订阅中心对应字段回退为手动录入(余额手填、倍率手填),不报错、不阻塞接入。\n- adapter 验证失败/超时 → 订阅可仍以「手动模式」创建,仅失去自动刷新与自动建 key,其余流程(手填 baseURL+key 建渠道)不受影响。\n- 官方来源无「自动建 key」时,接入流程退化为 §8.4 标准快速添加:用户填 API key,adapter 只补验证与余额。\n- 所有自动刷新遵守 `SubscriptionAutoRefresh` 默认关闭;连续失败自动停用自动刷新并在驾驶舱「人工待办」提示。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3,\n \"providerTimePricing\": {\n \"deepseek\": {\n \"effectiveFrom\": \"2026-07-20T00:00:00+08:00\",\n \"timeZone\": \"Asia/Shanghai\",\n \"peakMultiplier\": 2,\n \"peakWindows\": [\n { \"start\": \"09:00\", \"end\": \"12:00\" },\n { \"start\": \"14:00\", \"end\": \"18:00\" }\n ]\n }\n }\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n`providerTimePricing.effectiveFrom` 使用 RFC3339;修改该值即可调整激活时间,设为空字符串则停用对应规则。\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**Phase 3A 状态(2026-07-09)**:低风险、不改真实路由语义的子集已完成并接入真实链路——`Manager.collectAll()` 新旧画像对比产出变更事件(`DetectProfileChanges`)、`ProfileChangelogStore`(环形内存 + SQLite,30 天机会性清理)、`EventHub` 内存 pub/sub、`GET /api/health-center/changelog`(历史)+ `GET /api/health-center/events`(WebSocket 实时推送)、`AutoDiscoveryRunner` 发布 `discovery_completed`/`auto_mapping_applied`、前端 `ProfileChangelogTimeline.vue` 接入健康中心页并支持断线自动重连。均为只读展示,未修改 `SmartRouter`/`EndpointAttemptPolicy` 调度逻辑。落地时顺带发现并修复一个真实缺口:浏览器原生 WebSocket 无法设自定义 header,为此给 `middleware.getAPIKey` 增加了 `Sec-WebSocket-Protocol` 鉴权回退(业界标准做法,仅新增一个 key 来源,不降低现有校验)。\n\n**Phase 3B(已全部完成,2026-07-09)**:模型自动映射、自动恢复探测、晋升/降级机制会直接改变真实调度决策,风险明显高于 3A,四个子项已分批全部完成。§12.2 P1.5 的 SQLite schema version / 版本化 migration / `OriginType`&`OriginTier` 旧配置 backfill / `killSwitch`+按 task class/channel disable **已于 2026-07-09 补强完成**(详见 §12.2 P1.5)。**Phase 3B-1(自动恢复探测补全)已于 2026-07-09 完成**:修复了 L2 探测子系统里此前从未打通的两个真实 bug——① `collectAll()` 每轮把新构造的画像整行 Upsert,会把 `ProbeWorker` 刚写入的 `LastProbeAt`/`ProbeSuccess`/`ConsecutiveProbeSuccess` 等字段清零,导致 6 小时探测冷却期形同虚设(修复:`carryForwardProbeFields` 在 Upsert 前搬运 Probe* 字段,不影响 L1 诊断字段的现有覆盖逻辑);② `extractRawAPIKey` 是返回 `KeyMask` 掩码值的占位 stub,探测请求实际带的是假 key(修复:新增 `APIKeyResolver` 回调 + `Manager.ResolveAPIKey`,通过遍历配置里对应渠道的 `APIKeys` 逐个计算 `KeyHashFromAPIKey` 比对来还原明文;resolver 未注入或未命中时 fail-open 跳过探测且不消耗预算)。同时补齐了 `degraded/limited → healthy` 的连续成功恢复路径(新增 `KeyEndpointProfile.ConsecutiveProbeSuccess` 计数器 + `HealthCheckConfig.ProbeRecoveryThreshold`,默认连续 2 次探测成功才恢复,避免单次探测噪声导致状态抖动;探测失败计数器清零)。**Phase 3B-3(StabilityTier 晋降级滞后)已于 2026-07-09 完成**(提交 664d3fff):在原本每轮从瞬时 1h 窗口重算的无记忆 `StabilityTier` 上加一层连续窗口滞后——新增 `KeyEndpointProfile.EffectiveStabilityTier`/`StabilityPendingTier`/`StabilityPendingStreak` 三字段 + 纯函数 `applyStabilityHysteresis`(对称阈值:连续 N 轮同值才切换生效档,`HealthCheckConfig.StabilityHysteresisWindows` 默认 2;首次画像直接采用,不等待阈值,避免新渠道卡在零值),`AggregateChannelProfile` 改读 `EffectiveStabilityTier`(空则回退 `StabilityTier`)。晋升/降级机制的数据模型仅限于 StabilityTier,不改变 QualityTier/SpeedTier/CostTier 语义。**Phase 3B-2(模型自动映射闭环)已于 2026-07-09 完成**(提交 0f02e988,随后修复一处 critical 集成 bug,提交 856372c4):新建 `ModelProfileStore`(SQLite,`model_profiles` 表)+ 扩展 `AutoDiscoveryRunner` 为每个发现模型写入 `ModelProfile`(仅 `AutoManaged` 渠道);新建 `ModelResolver` 实现 §5.4 五步逻辑(手动 `RedirectModel` 短路 → `GetModelProfiles` → `filterByCapabilityFloor` → `rankBySimilarity`);`scheduler` 包新增 `ModelSupportResolverFunc` hook(fail-open,未注入或返回不支持时严格回退到原 `ExplainModelSupport`);`endpoint_policy.go` 的 `resolveMappedModel`(此前是永不触发的死代码——`profile.ModelMapping` 从未被写入)现调用 `ModelResolver`;`handlers/common` 在 `RedirectModel` 之后应用 `MappedModel` 覆盖请求体,并按 `EchoMappedModel` 配置回显 `X-CCX-Mapped-Model`/`X-CCX-Original-Model`/`X-CCX-Mapping-Source` 响应头。三条件安全门控:`ModelMapping.AutoResolve==true`(**默认由死配置的 true 改为 false**)AND `RoutingMode ∈ {assist, auto}` AND 无 `KillSwitch`,三者任一不满足则所有新增代码路径行为与今天字节级一致;且仅 `AutoManaged` 渠道触发,手动配置渠道完全不受影响。对抗性复核发现并修复一个真实 critical bug:`scoreEndpointForKey` 用空 `channelUID` 计算 `EndpointUID` 并写入 `modelByUID`,而 `handlers` 层用真实 `channelUID` 查 `modelByUID`,两者哈希永不相等导致 `MappedModel` 在 key 级调度路径上完全查不到(提交 856372c4 修复:命中画像后改用 `profile.EndpointUID`,新增回归测试锁定该不变量)。至此 Phase 3B 四个子项(自动恢复探测、StabilityTier 滞后、模型自动映射、active filter 前置判定)全部完成;SLO regression 自动回滚仍是独立子系统,留待后续单独立项。\n\n**范围**:\n- [x] 运行时指标驱动画像实时更新(Phase 3A:`collectAll()` 每轮 diff + changelog + WebSocket 推送)\n- [x] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)—— Phase 3B-2(2026-07-09 完成,提交 0f02e988 + 856372c4)\n- [x] active model filter / context filter 支持自动映射前置判定 —— Phase 3B-2:`scheduler.ModelSupportResolverFunc` fail-open 接入 `select.go`\n- [x] 自动恢复探测(limited/dead → healthy)—— Phase 3B-1(2026-07-09 完成):`APIKeyResolver` + `carryForwardProbeFields` + `ConsecutiveProbeSuccess`/`ProbeRecoveryThreshold`\n- [x] 晋升/降级机制(连续成功→升级,连续失败→降级)—— Phase 3B-3(2026-07-09 完成,提交 664d3fff):`StabilityTier` 连续窗口滞后(`EffectiveStabilityTier`),暂不含 QualityTier/SpeedTier/CostTier\n- [x] WebSocket 推送画像变更事件(Phase 3A:`GET /api/health-center/events`)\n- [x] 前端画像变更历史/时间线(Phase 3A:`ProfileChangelogTimeline.vue`)\n\n**预估工期**:2-3 周(3A、3B-1、3B-2、3B-3 均已完成;advisor 与全局 auto 的 SLO 回滚均已补齐)\n\n> 状态校正(2026-07-17):上方 Phase 3B 历史说明中的“SLO regression 自动回滚待单独立项”已完成;advisor 回滚与全局 auto 上线闸门/自动降级是两个独立保护层。\n\n### Phase 4:高级特性\n\n**Phase 4 状态(2026-07-09,全部完成)**:8 项全部实现并接入真实链路,均遵循\"默认关闭 + 显式 opt-in\"安全守则。\n\n**范围**:\n- [x] 多维度标签系统扩展(用户自定义标签)——Item 1\n- [x] 成本报表:按用户/模型/渠道/key 统计真实有效成本——Item 2(新增 `proxyKeyMask` 身份贯穿链路:`middleware.GetProxyKeyMask` → `ChannelLog`/`RequestRecord` → SQLite `proxy_key_mask` 列,供 Item 4 复用)\n- [x] Trusted advisor SLO regression 自动回滚——Item 3(默认关闭;`SLORollbackConfig`,连续 degrading 窗口数默认 3,不做自动恢复以避免震荡)\n- [x] 渠道推荐:根据使用模式推荐新渠道——Item 4\n- [x] 批量渠道管理(导入/导出/模板)——Item 5\n- [x] 订阅中心 provider adapter:可选自动刷新余额/套餐状态——Item 6(默认关闭;仅 openai/anthropic/google 且显式填写 BillingAPIKey 的订阅生效,中转/公益渠道继续手动维护)\n- [x] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板——Item 7\n- [x] A/B 测试:低比例统计抽样双发——Item 8(默认关闭,默认 1% 抽样 + 每小时 60 次硬预算;主请求路径不受影响,影子请求在主响应返回后异步发起)\n\n**关键实现说明**:\n- Item 3/6/8 均在 `AutopilotRoutingConfig` 新增独立配置块(`SLORollback`/`SubscriptionAutoRefresh`/`ABTest`),`Validate()` 统一做负值/越界兜底,保持与既有字段一致的防御模式。\n- Item 4 直接复用 Item 2 的 `proxyKeyMask` 身份标记,未重新发明身份识别机制。\n- **全局 auto 安全闸门(2026-07-17)**:请求终态按 15 分钟窗口无偏聚合,详细 trace 继续抽样;管理 API 只在 shadow/assist 已积累至少 500 个有效请求、24 小时连续观测且成功率/fallback/fail-open/p95 均达标时允许切换 `auto`。后台连续检测三个已完成 auto 窗口,相对最近七天安全模式基线持续恶化时,持久化降级到 `assist` 并记录 `autopilot_auto_safety_events`,不自动恢复。\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- ~~SQLite schema version 和幂等 migration。~~ **已完成**:`ensureSchemaVersion` 基于 `PRAGMA user_version`,当前 7 张表定版为 v1 基线;库版本高于代码版本时返回 error(fail-closed,不做任何写操作),交给下游 fail-open 分支接管。\n- ~~旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。~~ **已完成**:`ensureOriginBackfill` 只补 `\"unknown\"`,不做任何基于 URL/名称的猜测推断;已接线到 `Profiler.DeriveEndpointProfile`,`BreakTieByOriginTier` 现在能吃到真实数据(此前一直是 fallback 的 `unknown`)。\n- ~~profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。~~ **已确认满足并测试锁定**:`main.go` 现有结构本就是 fail-open(`NewProfileStore`/`NewManager` 出错只打日志、`autopilotManager` 保持 `nil`,下游全部有 `nil` 守卫),本次新增 `schema_migration_test.go` 用测试锁定这条不变量。\n- ~~全局 `killSwitch`、按 task class disable、按 channel disable。~~ **已完成**:新增 `AutopilotRoutingConfig.DisabledTaskClasses`/`DisabledChannelUIDs`;`CandidateFilterFor`/`executeFilter`/`BuildPlan` 三处一致接入,`smart_router_invariant_test.go` 覆盖\"task class 禁用后回退默认调度\"\"channel 禁用后不出现在候选里\"\"dry-run 与真实路径行为一致\"三条不变量。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。**部分完成**:`ReleaseController.EvaluateAndApplyRegression` 已实现三窗口回归检测和 `SafetyOverride` 降级机制;`RoutingModeActive` 已加入配置枚举和 `IsAutopilotActive`。\n\n#### P1.6 Trace v2 契约与灰度发布\n\n> 对应实施计划:`docs/superpowers/plans/2026-07-23-autopilot-trace-rollout.md`\n\n**Trace v2 核心变更:**\n\n- `RoutingDecisionTrace` 增加 `SchemaVersion`、`TraceRevision`、`RequestCorrelationId`、`ReleaseID`、`PolicyFingerprint`、`TargetMode`/`EffectiveMode`/`Cohort`、`SchedulerDecision`、`EndpointAttempts` 字段。\n- 新增 `TraceDetailV2`、`TraceSummary`、`SchedulerDecisionSummary`、`EndpointAttemptSummary` DTO;使用显式 DTO 落盘而非直接 JSON 化运行时结构。\n- `ComparisonStatus` 三态枚举(`matched`/`mismatched`/`uncompared`)替代裸 `bool`。\n- `SanitizeForPersistence` 与 `SanitizeForResponse` 双边界脱敏;`GenerateTraceUIDv2` 使用 `crypto/rand` 碰撞安全生成。\n- `RoutingReleaseSnapshot` 不可变发布快照,入口冻结后续只读。\n\n**SQLite v6 迁移:**\n\n- trace 表新增列:`schema_version`、`trace_revision`、`request_correlation_id`、`release_id`、`policy_fingerprint`、`persistence_class`、`details_json`。\n- 窗口表重建主键:增加 `release_id`/`policy_fingerprint`/`cohort` 维度,新增 `compared_count`/`matched_count`/`mismatch_count`/`uncompared_count` 全量比较计数。\n- safety event 表增加 `release_id`/`policy_fingerprint` 字段。\n\n**Autopilot 单一运行态(2026-07-28):**\n\n顶层 `off → shadow → assist → auto → active` 灰度状态机已经废止。系统启动后固定执行 Autopilot 自动路由:应用硬约束过滤并按评分重排,候选全部被过滤时 fail-open。旧配置中的 `mode` 值会在加载时统一归一化为 `auto`,不再提供模式切换 API 或管理界面。\n\n`AUTOPILOT_KILL_SWITCH` 与配置中的 `killSwitch` 是唯一的全局急停入口;启用后不注入 SmartRouter 和 EndpointAttemptPolicy,恢复调度器默认行为。管理端 dry-run 仍作为独立诊断能力保留,不属于运行模式。\n\n发布批次、策略指纹和 cohort 字段继续保留在 Trace 契约中,用于兼容历史数据及诊断聚合;`ReleaseController`、readiness 样本门槛、自动降级/恢复和灰度模式切换代码均已删除。\n\n**只读 Trace API:**\n\n| 接口 | 返回 | 约束 |\n|---|---|---|\n| `GET /api/autopilot/traces` | `TraceSummary[]` | 游标分页、release/cohort/mode 过滤、partial 标记 |\n| `GET /api/autopilot/traces/:traceUid` | `TraceDetailV2` | 404=未找到/已过期/未采样;503=存储不可用 |\n| `GET /api/autopilot/traces/stats` | `TraceStatsResponse` | 三态比较统计、Mode/TaskClass 分布 |\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | advisor/local-model 保留独立 shadow 门槛;全局 Kill Switch 可立即停止 Autopilot |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "4e440f5c3ed06ee1223b0a2e9f1c6630022a1754": {"t": 1785759328.1230729, "n": 167278, "h": "a23a81389bd60336", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.2.3 同订阅多 Key:能力共享与配额负载均衡\n\n**问题**:官方订阅 plan(Claude Max、Codex plan 等)允许用户持有多个 key,或购买多份相同套餐。若每个 endpoint 独立探测与配置,探测预算被浪费、配置重复;请求量大时还需要在同 plan 的多个 key 之间按官方 RPM 限速自动负载均衡,而不是打满第一个 key 才溢出到下一个。\n\n**能力检测去重(同一套餐只需一套能力检测和配置)**:\n\n```text\nSubscriptionUID 相同的 endpoint 共享一份「订阅级能力画像」:\n 模型列表 / vision / tool / reasoning 支持 / 协议兼容开关 → 只探测一次,\n 存在 SubscriptionProfile.SharedCapability,endpoint 画像以引用方式继承。\n\n仍然 per-endpoint 维护的:健康状态、延迟、熔断、用量窗口\n (key 可能被单独封禁/限速,能力相同不代表运行态相同)\n\n继承失效条件:某 key 实测模型列表哈希与订阅级不一致(GroupChangeDetector 信号)\n → 该 key 脱离继承、独立探测,并在 UI 标记「与套餐能力不一致」警告。\n```\n\n**配额负载均衡(Phase 1 shadow 展示,Phase 2 生效)**:\n\n```text\n每 key 维护配额余量视图:\n remainingRPM = officialRPM − 当前窗口已用\n officialRPM 来源优先级:内置 manifest > 用户配置 > RateLimitDiscoverer 建议\n\n同订阅多 key 时按「剩余余量加权」分配请求(余量大者优先、\n窗口最早重置者兜底),替代顺序打满:\n 仅当渠道 autoManaged 且同一 SubscriptionUID 下 key 数 > 1 时启用;\n 否则保持现有 sequential/random key 顺序策略不变。\n\n某 key 的窗口配额耗尽 → 临时降权并切到同订阅其他 key,\n不熔断整个渠道(这是配额耗尽,不是故障)。\n```\n\n### 3.2.4 订阅用量窗口 (Usage Windows)\n\n部分官方 plan 提供 5h / 日 / 周 / 月等窗口的用量查询与限制(如 Claude 订阅的 5h 滚动窗口 + 周配额)。在 key 的位置展示这些窗口,帮助用户掌握使用情况:\n\n```go\ntype UsageWindow struct {\n Window string `json:\"window\"` // \"5h\" | \"day\" | \"week\" | \"month\"\n Used float64 `json:\"used\"` // 已用量\n Limit float64 `json:\"limit\"` // 上限,0 = 未知\n Unit string `json:\"unit\"` // requests | tokens | credits | percent\n ResetsAt time.Time `json:\"resetsAt\"` // 窗口重置时间\n Source string `json:\"source\"` // official_api | response_header | local_metering\n FetchedAt time.Time `json:\"fetchedAt\"`\n}\n// KeyEndpointProfile 与 SubscriptionProfile 均增加 UsageWindows []UsageWindow\n```\n\n数据来源优先级:\n\n```text\n1. 官方用量 API:有 provider adapter 的 plan 主动拉取(§Phase 4 订阅 adapter 的可提前子集)\n2. 响应头:anthropic-ratelimit-* / x-ratelimit-* 等(复用 RateLimitDiscoverer 的信号 tap)\n3. 本地计量兜底:CCX 按 key 累计请求数/token(误差来自窗口起点对齐,标注 Source=local_metering)\n```\n\nUI 要求:key 行内显示主窗口(5h)用量进度条,悬浮展开全部窗口;用量 >80% 黄色预警、超限红色并显示预计重置时间。调度联动(Phase 2):窗口余量进入配额负载均衡权重,窗口耗尽的 key 临时降权而非熔断。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n#### 4.2.1 内置模型清单 (Builtin Models Manifest)\n\n**问题**:部分官方订阅 plan 的入口没有正确的 models 接口——要么不存在(404)、要么需要额外权限、要么返回与订阅实际可用模型不符的列表(典型:Claude 订阅 OAuth 入口、Codex plan 入口、部分官方企业网关)。对这些 baseURL 做模型发现会失败或产出错误画像。\n\n**方案**:CCX 内置「已知官方 baseURL → 模型清单」映射表,命中时直接返回内置内容,完全跳过上游 models 接口:\n\n```go\n// backend-go/internal/config/builtin_models_manifest.go(生成式,随模型注册表更新)\ntype BuiltinModelsManifest struct {\n BaseURLPattern string `json:\"baseUrlPattern\"` // 精确 host 或前缀匹配,如 \"api.anthropic.com\"\n ServiceType string `json:\"serviceType\"` // messages/responses/…\n PlanHint string `json:\"planHint,omitempty\"` // 订阅类型提示(如 \"claude_subscription\")\n ModelIDs []string `json:\"modelIds\"` // 该入口实际可用的模型清单\n DisableProbe bool `json:\"disableProbe\"` // true 时 Discovery 跳过 GET /v1/models\n}\n```\n\n**两个生效位置**:\n\n```text\n1. Discovery(§4.2 流程第 2 步之前):\n baseURL 命中 manifest 且 DisableProbe=true\n → 跳过 GET /v1/models,直接用 ModelIDs 生成 ModelProfile[]\n → ProfileSource = \"builtin_manifest\",不再因 models 接口失败而误判 misconfigured\n\n2. /v1/models 代理响应(现有 Models API):\n 客户端向 CCX 请求 /v1/models 时,聚合结果中该渠道的贡献\n 直接取内置清单,不向上游发起 models 请求\n (渠道显式配置了 supportedModels 时仍以用户配置优先)\n```\n\n**维护与覆盖顺序**:\n\n```text\n渠道显式 supportedModels(用户手动) > 内置 manifest > 上游 models 接口实探 > 内置候选模型回退\n```\n\nmanifest 与模型注册表同源生成(`generated_model_registry.go` 同级),新模型发布时随注册表一起更新;HealthAnalyzer 对命中 manifest 的 endpoint 不将「models 接口 404/403」计入 misconfigured 信号。\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n MappedEffort EffortLevel // 自动映射的 effort 级别(空=未决定)\n MappedEffortDecided bool // effort 是否由 Autopilot 决定\n EndpointUID string // 稳定 endpoint ID\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n\n // ResolvedModelByEndpointUID(旧 API):仅返回模型名,向后兼容。\n // ResolvedTargetByEndpointUID(新 API):返回 model + effort 原子决策。\n // 两者从同一份 targetByUID 派生,避免两个真值源。\n ResolvedModelByEndpointUID func(endpointUID string) string\n ResolvedTargetByEndpointUID func(endpointUID string) *ResolvedRouteTarget\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20;assist 下 dead/misconfigured 额外 -100 并保留在队尾\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法细分时进入 `general`;有规范 Knowledge 基准则使用其软评分,缺少直接证据时仍取 0.5 中性值。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n**手动 L3 第一版状态(2026-07-15)**:已实现 `pq-v1-20260715` 固定 JSON canary,覆盖\nMessages、Chat Completions、Responses 与 Gemini 上游协议。调用方只能指定 `endpointUid`、\n`modelId` 和 1~3 次重复数,不能注入 prompt。评分仍按 40% 完整性、30% 语义正确性、\n15% 严格 JSON 格式、15% 延迟执行;第一版使用非流式完整响应延迟作为首 token 延迟代理,\n后续引入流式采样时再替换该维度。单次有效样本 confidence=0.6,最多三次提升到 0.8;\n失败样本按 0 分计入均值,全部失败不写画像。\n\n安全边界:仅管理 API 手动触发,不启动自动 worker;当前进程按 UTC 日设置 12 次硬预算,\n请求前原子预留,单次 90 秒超时、响应体上限 64 KiB。同一 endpoint×model 不并发执行;\n单次请求最多允许 2048 个输出 token,以容纳推理模型的隐藏思考并限制最坏额度消耗。\n对声明可控推理的模型固定采用最低可用 effort;Claude 兼容端点若只暴露\n`reasoning_content` 而未声明 thinking 模式,则与 capability-test 一致显式关闭 thinking。\n原始模型输出和明文 Key 既不落盘也不返回,只保存分数、canary 版本、置信度和脱敏判定。\n`user_feedback` 仍高于 `probe`,自动发现重建 ModelProfile 时必须保留既有质量证据。\n跨进程共享预算与 LLM-as-judge/语义向量评分仍属于后续阶段。\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n\n乘数是预设内建的常量,不对外暴露;非法或未知 mode 归一化回退 `balanced`。\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法细分的通用任务;缺少基准证据时中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general;有规范 Knowledge 基准时使用其软评分,缺少直接证据时取 0.5 中性。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? canonicalBenchmark[model][domain] * providerQualityFactor\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 规范模型基准 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n#### 5.7.4 规范模型能力基准\n\n`shared/model-registry/ccx_model_registry.json` 的 `benchmarkProfiles` 独立保存规范模型能力上界,不与 `upstreamCapabilities` 混合。后者仍是 context、vision、tool calls 等硬能力的事实源;benchmark 只参与 Autopilot 软评分和 trace 解释,绝不改变 `supportedModels` 或 CapabilityFloor。\n\n```text\nEffectiveCapability[domain]\n = CanonicalCeiling[model, domain]\n × ProviderQualityFactor[endpoint]\n\nProviderQualityFactor = 1 - confidence × (1 - providerQualityScore)\n无渠道质量证据或 confidence<0.5 时 factor=1;可信证据只能从规范模型上界向下折算。\n```\n\n基准数据必须保留领域向量、来源、核验日期、lane、共享结果数和可比类别覆盖率,不能只保存站点 Overall。当前 BenchLM 接入为 `provisional`:Claude Opus 4.8、GPT-5.6 Terra、GPT-5.6 Sol 各有 30 个共享结果,8 类中 5 类可比,核验日期为 2026-07-13。\n\n领域映射:Knowledge → `general`,Math → `reasoning`,Coding → `coding` / `code_review`,Agentic → `agentic`,Multimodal → `aesthetics_ui`(仅弱代理,解释置信度减半)。Writing / Translation 没有直接类别证据时继续回退家族种子或 0.5。BenchLM 的 cohort 归一化、缺失项插补和外部校准决定了 Overall 不是绝对能力值,因此路由只消费可映射的原始类别分。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n#### 5.8.4 联合决策的现状实现(model + effort 原子化)\n\n> 状态说明(2026-07-26):本节描述的联合决策链路已实现并接入真实请求路径(关联计划 [2026-07-26-autopilot-model-effort-joint-routing.md](../superpowers/plans/2026-07-26-autopilot-model-effort-joint-routing.md) 的 Task 1~7)。**渠道级手工 `modelMapping` / `reasoningMapping` 表目前仍然存在且仍然生效**,退役是该计划的 Task 8,尚未执行;手工表当前的优先级关系见下文。\n\n**决策对象。** 自动路由的返回值不再是裸模型名,而是 `autopilot.ResolvedRouteTarget`(`backend-go/internal/autopilot/route_target.go`):\n\n```go\ntype ResolvedRouteTarget struct {\n Model string // 目标模型 ID\n Effort EffortLevel // 目标思考档位;空串表示不改写\n EffortDecided bool // true 表示 effort 由 Autopilot 决定;false 为 passthrough\n Reason string // 决策原因,用于 Trace 与日志\n}\n```\n\n`EffortDecided=false` 且 `Effort` 为空是合法的 fail-open 结果:模型可能被改写,但思考参数保持原样。\n\n**决策发生的位置。** `autopilot/endpoint_policy.go` 的 `resolveMappedModel()` 是统一入口,内部分两路:`resolveManualMapping()` 处理渠道级显式 `ModelMapping`(仍是当前优先级更高的一路,返回的 `ResolvedRouteTarget.EffortDecided` 恒为 `false`,即手工映射只改模型不改档位);`resolveAutoModel()` 调用 `ModelResolver` 做自动模型解析,并在 `model_resolver.go` 的 `rankEligibleModels()` 中按 `SupportedEffortLevels` × `PerTaskClass` 展开 `(model, effort)` 候选、应用 `EffortQualityBonus` 与 anti-effort-inflation 反膨胀 tiebreak(同质量档优先选低 effort)、并按 `EffortFloor` 过滤掉低于任务下界的候选。产出的 `ResolvedRouteTarget` 经 `buildResolvedTargetLookup()` 挂到 `EndpointAttemptPolicy.ResolvedTargetByEndpointUID`,与旧的 `ResolvedModelByEndpointUID`(仅模型,向后兼容)并列。\n\n**决策如何到达上游请求。** `handlers/common/upstream_failover.go` 的 `TryUpstreamWithAllKeys` 在为每个 endpoint 尝试构建请求前,通过 `ResolvedTargetByEndpointUID` 取得 `*ResolvedRouteTarget`,交给 `atomicModelEffortRewrite()` 做一次原子改写:先用 `sjson` 改写 `model` 字段;若 `EffortDecided=true` 且 `Effort` 非空,再检查目标模型的 `ThinkingMode`——`adaptive_only` 的模型跳过思考参数注入(模型自身决定思考深度,守卫逻辑对齐 `providers/claude.go` 的 `applyClaudeReasoningEffort`),否则调用 `config.ApplyReasoningParamStyle()` 写入。模型改写与档位改写共用一次函数调用,任一步 `sjson`/`json` 处理失败即整体放弃、保持原始 body,不会出现\"模型改了、档位没改\"的中间态。\n\n**各协议的注入形态。** `config.ApplyReasoningParamStyle(req, style, effort)`(`config/config_utils.go`)按 `upstream.ReasoningParamStyle` 三态分支:\n\n```text\nstyle=\"thinking\" → Claude 派系:写 thinking.type=enabled + thinking.effort=;\n effort 为 off/none 时写 thinking.type=disabled;同时清理互斥字段\n reasoning / reasoning_effort / thinking.budget_tokens\nstyle=\"reasoning_effort\" → OpenAI Chat 派系:写顶层 reasoning_effort 字段,清理 reasoning\nstyle=\"\" / 默认 → Responses 派系:写 reasoning.effort 对象形态\n```\n\n**Gemini 派系是尚未接通的缺口。** 请求侧提取(`extractGeminiEffortExplicit()`,`handlers/common/reasoning_log.go`)已经能读取 `generationConfig.thinkingConfig.thinkingLevel` 并识别显式 `none`(`thinkingBudget=0`),这条读路径用于日志与 `RequestProfile.ClientEffort` 提取。但写路径——把联合决策的 `target.Effort` 写回 Gemini 请求体的 `thinkingConfig.thinkingLevel`——目前代码中不存在:`atomicModelEffortRewrite()` 唯一的写出口是 `ApplyReasoningParamStyle()`,它只覆盖 Claude/Chat/Responses 三种形态,没有 Gemini 分支;`handlers/gemini/handler.go` 的 `buildProviderRequest()` 也没有调用任何 effort 注入函数。也就是说,Gemini 渠道当前仍会经过 endpoint 级模型改写(`atomicModelEffortRewrite` 会改写 `model` 字段本身),但联合决策选中的思考档位不会落到 Gemini 请求体上,效果等同于 fail-open passthrough。此项在原计划里归属 Task 5 的\"Gemini 派系注入路径\",尚未实现。\n\n**可观测性字段。** 原子改写点会向 `gin.Context` 写入两个字段供 ChannelLog 使用:`effortDecisionSource`(`autopilot` 表示由联合决策产出,`passthrough` 表示未改写或手工映射路径)、`effortClampedByClient`(当客户端显式声明的档位低于 Autopilot 选择的档位时置 `true`,由 `isEffortClampedByClient()` 基于 `EffortLevelOrdinal` 比较得出)。\n\n**手工映射表的现状。** `resolveMappedModel()` 当前的分支顺序是\"显式 `ModelMapping` 优先,自动决策兜底\"——与原计划 Task 7 描述的\"反转优先级\"目标相反的方向仍是默认值;反转开关已作为可配置项落地(提交 `d15e776e`),但默认状态未切换,`ModelMapping` / `ReasoningMapping` 两张表本身尚未删除,`config.ResolveReasoningEffort()`(`config/config_utils.go`)与 provider 层(如 `providers/claude.go`)仍会读取 `ReasoningMapping` 独立生效。表的整体退役是 Task 8 的范围,未开始。\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**请求意图边界(ModelRoutingPolicy)**\n\n跨模型替代不是所有模型的默认能力,只对白名单客户端入口开放:\n\n```text\nmessages:\n fable / opus / sonnet / haiku 及对应 claude-* 完整模型名 → adaptive\nresponses:\n gpt-5.6* / gpt-5.5 / gpt-5.4* / codex-auto-review → adaptive\n其他协议或模型:\n exact-only,只能在探测到相同规范化模型 ID 的 endpoint 之间调度\n```\n\n`adaptive` 允许在满足 Capability Floor 后选择 GLM、MiMo 等其他模型族;`exact-only` 禁止跨模型替代。用户显式配置的 `modelMapping` 仍优先于该策略。调度预览和 endpoint 实际解析必须调用同一分类函数,避免诊断结果与真实请求分叉。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ModelProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选优\n // 上下文窗口只负责准入,不对更大的窗口扣分。\n // 排序优先级:\n // a. 模型质量档越高越优先\n // b. provider 已确认的同档模型能力顺序\n // c. 带置信度折算的供应商实测质量\n // d. 探测延迟\n // e. 同渠道/provider 的模型相对消耗倍率\n // 倍率相同或缺失时再比较公开模型成本\n // f. 同模型族与 model ID 仅作为确定性兜底\n best := rankEligibleModels(eligible, requestModel)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n同渠道套餐倍率与公开 USD/Token 价格是不同单位,禁止直接混算。模型解析器先比较 provider\n提供的相对消耗倍率;只有双方倍率相同,或双方均无倍率时,才回退到统一输入/输出规模下的\n公开价格。优云智算当前内置倍率如下(数字越小越省,`glm-5.2` 的 2 次为限时值):\n\n| 模型 | 每次调用套餐消耗 |\n|------|-----------------:|\n| `glm-5.2` | 2 |\n| `glm-5.1` | 6 |\n| `MiniMax-M2.7` | 2 |\n| `kimi-k2.6` | 5 |\n| `deepseek-ai/DeepSeek-V3.2` | 1 |\n| `deepseek-v4-flash` | 1 |\n\nprovider 可以额外声明同一质量档内已确认的模型能力顺序,但只有该档全部候选都命中该元数据\n时才比较,避免把未知模型误判为弱模型,也保证排序不依赖候选输入顺序。优云模板当前记录\n`glm-5.1 > kimi-k2.6`,因此前者即使消耗 6 次,也会在后者的 5 次成本证据之前胜出。\n`glm-5.2` 即使消耗 2 次,也仍会在质量档高于 DeepSeek 候选时优先;V3.2 与 V4 Flash\n同为 1 次时,再由公开价格及最终确定性规则区分。静态 `modelMapping` 仅保留为用户显式覆盖,\n不承担自动替代策略。\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - autoManaged 的空 supportedModels 不表示支持全部,必须由 endpoint ModelProfile 裁决\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n当 ModelResolver 已正常初始化且路由处于 `assist/auto + autoResolve=true` 时,autoManaged 渠道的画像拒绝是权威结果,调度器不得再 fail-open 到空 `supportedModels` 的兼容语义。Resolver 不可用、开关关闭或 `off/shadow` 时仍保留原有 fail-open,保证急停和降级路径不依赖画像库。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n### 6.9 渠道兼容性统一自学习\n\n渠道级兼容性配置不应要求用户先踩坑、再去翻设置面板找开关。这一节描述如何把这些开关从\n「静态布尔」转为「运行时学习到的能力事实」。\n\n**优先级合成**(`config.resolveCompatSwitch`)\n\n```text\n最终生效值 = 学习结论 ?? 历史种子(14 天内) ?? 静态默认\n```\n\n六个兼容性字段(`NormalizeNonstandardChatRoles`、`StripImageGenerationTool`、\n`CodexNativeToolPassthrough`、`StripEmptyTextBlocks`、`PassbackReasoningContent`、\n`PassbackThinkingBlocks`)已从 `UpstreamConfig`/`UpstreamUpdate` 整体删除:管理面板不提供编辑\n入口,渠道更新接口不接受手工写入,完全交由运行时自动决策。不再有「用户当前显式设置」这一档——\n历史手工值不代表可信的长期事实,可能当初就设错,也可能上游后来有了新情况已不再适用,因此不作为\n可以无限期压过学习的强制覆盖,只作为学习结论缺失时的一次性低置信度提示。\n\n学习结论通过 `UpstreamConfig.LearnedCompatTraits`(运行时字段,`json:\"-\"`,不落盘)传递:\nfailover 在发送前按 渠道-Key-模型 查询 `ChannelCompatCache`,把结论注入当次请求专用的\nupstream 副本,provider 构造请求时读取。\n\n**历史手工配置降级为种子**(`migrateManualCompatSwitchesToSeeds`):老用户配置文件里的六个字段\n已从结构体删除,只能在启动时从磁盘原始 JSON 里读到历史手工值。迁移把这些值降级为\n`UpstreamConfig.CompatSeeds`(`map[string]CompatSeedEntry`,带 `ExpiresAt`):\n\n- 种子带 **14 天有效期**(`CompatSeedTTL`),过期后自动失效、不再参与判断,回落学习结论或静态\n 默认重新判断。种子与学习记忆分开存储且 TTL 不同(种子 14 天 vs 学习记忆 24 小时),因为二者\n 语义不同:种子是\"曾经的一个信号\",学习记忆是\"渠道-Key-模型级的当前能力事实\"。\n- 迁移保留 `false` 值:用户「显式关闭」同样是他曾观察到的行为证据。\n- 迁移当下不改变可感知行为(学习缺失时按种子生效),但学到真实结论后立即让位,种子过期后也\n 自动退场。\n- 幂等:已有种子的渠道跳过。\n- 自动托管渠道在 `RuntimeUpstreamForAutoManagedProvider` 里连种子一起清空,其兼容性完全由\n 厂商原生默认值加运行时学习决定。已知厂商协议事实(如 GLM 的 OpenAI 兼容协议要求\n `passbackReasoningContent`)不走种子这条给\"可能出错的历史手工值\"用的临时通道,而是在对应\n accessor 里按 `ProviderID`/`ServiceType` 直接计算为静态默认(`shouldPassbackReasoningContentByDefault`\n 之类),因为这是不会过期的协议事实而非猜测。\n\n**记忆载体**:`config.ChannelCompatCache`,落盘 `.config/channel_compat.json`,\n键 `channelUID:keyHash:model`,TTL 24h(与 `DeprecatedParamCache`/`SystemHeaderFilterCache` 一致)。\n`Record` 仅在结论新增或翻转时返回 `true`,调用方据此决定是否重试,防止记忆生效后死循环。\n\n**三条学习路径**(按上游信号强度区分,不可一概而论)\n\n| 信号类型 | 兼容项 | 学习方式 |\n| --- | --- | --- |\n| 硬 400/422 + 可正则匹配 | developer role 降级、Codex 专属工具剥离、历史 thinking 回传、图片工具剥离 | 错误驱动:`CompatTraitFromError` 识别 → 记忆 → 同 Key 立即重试 |\n| 无报错,仅响应形态差异 | `passbackReasoningContent`、`stripEmptyTextBlocks` | 探针驱动:首次遇到该组合时 singleflight 后台探测一次 |\n| 厂商文档已公布约束 | Kimi 固定值参数、`tool_choice`、`thinking` | 前置规避:`ApplyKnownParamConstraints` 发送前直接改写,无需失败一次 |\n\n**分层职责**:`upstream_failover` 负责识别信号、写缓存、编排重试、把结论注入 `upstreamCopy`;\n各 provider 在构造上游请求时读取合成值并执行改写。这样 developer role 降级发生在\nResponses→Chat 转换**之后**的 `reqMap` 上,避开「failover 层拿到的是 Responses `input` 而非\nChat `messages`」这个陷阱。\n\n**防误判约束**(学错并持久化比不学更糟)\n\n- 双条件门控:正则命中之外,还要求请求侧确实携带对应结构(`BodyHasDeveloperRole` 等)\n- 单次重试上限:`Record` 返回 `false` 即不再重试\n- 不跨维度污染:结论不外溢到同渠道其他 Key/模型\n- 仅 400/422 参与学习;429/5xx/超时属容量问题,不是能力问题\n- fail-open:缓存损坏、探测失败一律退化为静态默认,不阻断代理请求\n\n**图片工具归因修正**:图片生成受限时,原实现直接拉黑 (Key,模型)。现改为先学习\n「需剥离图片工具」并重试修复请求,只有剥离后仍失败才回落拉黑——从「惩罚渠道」改为「先修复请求」。\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\nGET /api/health-center/provider-quality/budget → 查看本进程手动 L3 每日预算\nPOST /api/health-center/provider-quality/probe → 对指定 endpoint×model 执行固定质量 canary\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n#### 8.0.1 实现现状与演进方向(2026-07-09 核实)\n\n四界面在 **Web 前端(`frontend/src`)已大面积落地**,不是纯文档:\n\n| 界面 | Web 实现 | Desktop 实现 |\n|------|----------|--------------|\n| 健康中心 | ✅ `HealthCenterView.vue` + `HealthCenterStats`/`HealthChannelTable`/`HealthChannelDetail`/`ProfileChangelogTimeline`,路由 `/health` | ❌ 无 |\n| 订阅中心 | ✅ `SubscriptionsView.vue` + `SubscriptionPlanTable`,CRUD + 刷新余额,路由 `/subscriptions` | ⚠️ 独立旧实现 `subscriptions/SubscriptionTab.vue`(与 Web 两套代码) |\n| 渠道中心 | ✅ `ChannelOrchestration.vue`(六态 `ChannelHealthBadge`、来源 chips、`autoManaged` 图标齐备) | ❌ 未同步 |\n| 管理面板 | ⚠️ `AutopilotModePanel.vue` 仅暴露 mode/killSwitch/costPreference | ❌ 无 |\n| 驾驶舱 | ✅ `CockpitView.vue`(观测型总览:健康统计+余额汇总+渠道推荐) | ⚠️ `cockpit` tab 渲染的是会话 Dashboard,职责不同 |\n\n**与本设计意图的关系**(对齐用户「自动调度为主、废旧手动驾驶舱」的诉求):\n- **旧的「手动调度驾驶舱」在 Web 端已不存在**。`CockpitView` 已是 §8.7 定义的观测型总览,只回答健康/省钱/风险/待办四问题,不做渠道配置编辑,符合「程序自动选、人只观测+有限干预」的方向。无需再废除,只需继续收敛为纯观测。\n- **统一提交入口已存在**(§8.4 快速添加 + §8.5 订阅中心),new-api 集成(§8.5.1)在此之上把「填 baseURL+key」进一步自动化。\n\n**前端演进待办**(本设计新增/明确):\n1. **补 `ManualRoutingIntent` 前端**:这是用户要的「有限偏向性干预」,后端已有、两个前端均未实现(§8.3 试用意图入口尚缺)。\n2. **Desktop 前端对齐 Web**:健康中心/渠道自动托管/管理面板/观测型驾驶舱在 Desktop 几乎空白,需同步或复用 Web 组件,避免长期维护两套。\n3. **管理面板补字段**:暴露 `l2ProbeEnabled`、探测预算、new-api/多来源订阅 adapter 开关(§8.5.2)等,当前只有 3 个字段。\n4. **订阅中心接 §8.5.1/§8.5.2**:新增「new-api」订阅类型与 Provider Adapter 能力矩阵驱动的动态表单。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.5.1 new-api 订阅集成(统一账号接入)\n\n**目标**:用户在订阅中心手填一个 new-api(及其 fork)账号的「系统访问令牌」即可,CCX 自动用该令牌创建代理专用 key、拉取分组倍率、查询额度余额,并把「站点 baseURL + 新建 key」自动落成一个上游渠道,交给 Autopilot 探测与调度。\n\n**范围约束**:\n- 只使用 new-api 用户在「个人设置」生成的 **系统访问令牌(access_token)** 一种凭据;令牌用于 API 调用的身份验证,属敏感数据,须妥善加密存储。\n- **不做** cookie 登录 + 2FA + 导出后台渠道明文 key 的管理员迁移路径(all-api-hub 的 newApiSession 能力),本设计不涉及。\n- new-api 归入 `OriginType=relay`、`OriginTier=second`(中转站),信任等级不因自动化提升;实时质量仍由运行画像决定。\n- 认证令牌像其他订阅凭据一样由用户手动录入,CCX 不代替用户在 new-api 侧注册或登录。\n\n**认证与凭据模型**:\n\nnew-api family 的管理接口鉴权(依据 all-api-hub 传输层实现):\n\n```text\nAuthorization: Bearer # 系统访问令牌;raw 模式可去掉 Bearer 前缀\nNew-API-User: # 当前用户 ID(必带)\nUser-id: # fork 兼容回退(Veloera/voapi/Super-API 等)\nContent-Type: application/json\n```\n\n统一响应信封:`{ \"success\": bool, \"data\": T, \"message\": string }`;解析时先判 `success`,再取 `data`。\n\n**SubscriptionProfile 扩展**:\n\n在 §3.2.2 的 `SubscriptionProfile` 基础上,为可自动接入的订阅新增 provider 集成字段(沿用「默认关闭 + 显式 opt-in」守则):\n\n```go\n// SubscriptionProfile 新增字段(复用 Phase 4 Item 6 的 SubscriptionAutoRefresh 开关)\ntype SubscriptionIntegration struct {\n Kind string `json:\"kind\"` // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | ...\n BaseURL string `json:\"baseUrl\"` // new-api 站点地址(同时作为新建渠道的上游 baseURL)\n AccessToken string `json:\"-\"` // 系统访问令牌;加密存储,不出 API 响应\n UserID string `json:\"userId\"` // New-API-User 头所需\n AuthTokenMode string `json:\"authTokenMode,omitempty\"` // \"bearer\"(默认) | \"raw\"\n // 自动建 key 的模板\n ProvisionKeyName string `json:\"provisionKeyName,omitempty\"` // 默认 \"ccx-autopilot\"\n ProvisionGroup string `json:\"provisionGroup,omitempty\"` // 建 key 时指定分组,空=默认分组\n ProvisionModels []string `json:\"provisionModels,omitempty\"` // model_limits,空=不限制\n // 自动同步产物(回填,只读展示)\n ProvisionedTokenID int `json:\"provisionedTokenId,omitempty\"`\n LinkedChannelUID string `json:\"linkedChannelUid,omitempty\"`\n}\n```\n\n`AccessToken` 必须与现有敏感字段一样走加密存储,序列化到前端时脱敏(仅显示尾部若干位)。\n\n**new-api 接口清单**(本集成实际调用的子集,路径以主线 new-api 为准,字段名保持与上游一致):\n\n| 用途 | 方法 | 路径 | 请求体 | 响应关键字段 |\n|------|------|------|--------|--------------|\n| 校验令牌 + 查用户/余额 | GET | `/api/user/self` | — | `data.id`、`data.username`、`data.quota`(剩余额度)、`data.used_quota` |\n| 拉分组倍率 | GET | `/api/user/self/groups` | — | `data: { : { desc, ratio } }` |\n| 站点全部分组 | GET | `/api/group` | — | `data: string[]` |\n| 账号可用模型 | GET | `/api/user/models` | — | `data: string[]` |\n| 创建代理 key | POST | `/api/token/` | `CreateTokenRequest`(见下) | `data.key` / `success` |\n| key 列表 | GET | `/api/token/?p={p}&size={n}` | — | `data.items[]`(含 `id/key/name/status/remain_quota`) |\n| 更新 key | PUT | `/api/token/` | `{ ...token, id }` | `success` |\n| 删除 key | DELETE | `/api/token/{id}` | — | `success` |\n\n`CreateTokenRequest`:\n\n```json\n{\n \"name\": \"ccx-autopilot\",\n \"remain_quota\": 0,\n \"expired_time\": -1,\n \"unlimited_quota\": true,\n \"model_limits_enabled\": false,\n \"model_limits\": \"\",\n \"allow_ips\": \"\",\n \"group\": \"\"\n}\n```\n\n`quota` 是 new-api 内部额度单位(非美元),换算比例读 `GET /api/status` 的 `price` / `PaymentUSDRate`(无鉴权),或允许用户在订阅中手填换算率;MVP 直接以 quota 原值展示 + 站点换算率估算余额。\n\n**接入流程**(订阅中心「新增订阅 → 类型选 new-api」):\n\n```text\n1. 用户输入:站点 baseURL + 系统访问令牌(access_token) + userId\n (userId 可选自动获取:先用令牌调 /api/user/self 拿 data.id 回填)\n2. 校验:GET /api/user/self\n ├─ success=false / 401 → 令牌无效或 userId 不匹配,报错停止\n └─ 成功 → 记录 username、quota,创建 SubscriptionProfile(provider=new_api, originTier=second)\n3. 拉分组:GET /api/user/self/groups → 写入 GroupMultipliers({group: ratio})\n GET /api/user/models → 记录账号可用模型,供渠道 supportedModels 参考\n4. 建 key:POST /api/token/(按 ProvisionKeyName/Group/Models 模板)\n ├─ 已存在同名 ccx-autopilot key → 复用其 id,不重复创建(列表比对 name)\n └─ 新建成功 → 回填 ProvisionedTokenID,取 data.key 作为渠道 apiKey\n5. 自动建渠道:用 baseURL + 新建 key 创建一个上游渠道\n ├─ channelKind 由用户选择或按站点探测(messages/chat/responses/gemini)\n ├─ status=unknown,autoManaged=true,OriginType=relay\n ├─ 建立 SubscriptionUID → ChannelUID 链接(回填 LinkedChannelUID)\n └─ 触发 §8.4 的 Discovery + 能力测试标准流程\n6. 完成:订阅卡片显示余额、分组倍率、已绑定渠道、探测进度\n```\n\n关键点:new-api 集成**不额外造探测逻辑**,第 5 步之后完全复用现有 §8.4「添加并探测」链路;集成层只负责「令牌 → key + 分组 + 余额 + baseURL」的自动填充,把手填 baseURL/key 的动作自动化掉。\n\n**刷新与同步**(复用 `POST /api/subscriptions/{uid}/refresh`):\n\n| 触发 | 动作 |\n|------|------|\n| 手动刷新余额 | `GET /api/user/self` 更新 `Balance` + `BalanceUpdatedAt` |\n| 定时刷新(可选,opt-in) | 复用 Phase 4 Item 6 `SubscriptionAutoRefresh`;new-api 作为 `relay` provider adapter 纳入,默认关闭 |\n| 分组倍率变更 | 刷新时对比 `/api/user/self/groups`,`GroupMultipliers` 变化 → 更新并提示「套餐倍率已变」 |\n| key 失效检测 | 渠道侧探测到该 key 全 401 → 订阅卡片提示「key 可能已删除/禁用」,提供「重新建 key」一键动作(重跑第 4-5 步,复用旧渠道,只换 apiKey) |\n| 可用模型变更 | `/api/user/models` 结果哈希变化 → 触发对应渠道重新 Discovery |\n\n**安全与边界**:\n- `access_token` 权限等同 new-api 账户,须加密存储、脱敏展示、禁止写入日志;刷新失败连续 N 次自动停用该订阅的自动刷新,只保留手动。\n- 自动建 key 默认 `unlimited_quota=true` 但可由用户改为限额模板;建 key 是有副作用的写操作,须在接入前明确告知用户「将在 new-api 侧创建一个名为 ccx-autopilot 的令牌」。\n- new-api 是 `OriginTier=second`,**不得**用作隐私敏感的 routing advisor / classifier(遵守 §3.2.1 约束)。\n- fork 兼容:请求同时带 `New-API-User` 与 `User-id` 头;响应信封字段名(`success/data/message`)在部分 fork 可能不同,adapter 需容错解析,失败时回退手动录入。\n- 一个 new-api 订阅默认只自动建/绑定一个渠道;用户如需该站多协议渠道,可在同订阅下手动增建,共用同一 key。\n\n### 8.5.2 订阅 Provider Adapter 框架(多来源余额与接入)\n\n§8.5.1 的 new-api 集成不是特例,而是「订阅 Provider Adapter」框架的一个实现。Phase 4 Item 6 已落地 openai/anthropic/google 的余额刷新 adapter,本框架把它抽象成统一接口,让 new-api 及后续来源(GitHub Copilot、Codex、Nvidia、MiniMax、DeepSeek 等)按相同契约接入,避免每加一个来源就散落一套逻辑(DRY)。\n\n**统一接口**:\n\n```go\n// backend-go/internal/autopilot/subscription/adapter.go\ntype ProviderAdapter interface {\n Kind() string // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | \"copilot\" | \"deepseek\" | ...\n\n // 校验凭据,返回账号身份(用户名/账户标识);失败即凭据无效\n Verify(ctx context.Context, cred Credential) (AccountInfo, error)\n // 查余额/额度;不支持返回 ErrUnsupported,由 UI 降级为手动录入\n FetchBalance(ctx context.Context, cred Credential) (Balance, error)\n // 拉分组倍率;仅 new-api 类中转有,官方直连返回 ErrUnsupported\n FetchGroups(ctx context.Context, cred Credential) (map[string]GroupRatio, error)\n // 自动创建代理 key;官方直连通常不支持(key 即用户 API key),返回 ErrUnsupported\n ProvisionKey(ctx context.Context, cred Credential, tmpl KeyTemplate) (ProvisionedKey, error)\n // 可用模型列表;用于渠道 supportedModels 参考\n FetchModels(ctx context.Context, cred Credential) ([]string, error)\n}\n```\n\n每个能力独立、可选:adapter 只实现自己支持的部分,不支持的统一返回 `ErrUnsupported`(接口隔离原则),UI 据此隐藏/降级对应入口。所有 adapter 共用 `SubscriptionAutoRefresh` 的 opt-in 开关与 `Validate()` 兜底,与 Phase 4 现有防御模式一致。\n\n**能力矩阵**(✅ 支持 / ⚠️ 部分或需确认 / ❌ 不支持,走手动):\n\n| 来源 | Kind | 验证 | 查余额 | 拉分组 | 自动建 key | OriginTier |\n|------|------|:----:|:------:|:------:|:----------:|------------|\n| new-api family | `new_api` | ✅ | ✅ quota | ✅ | ✅ | second |\n| OpenAI 官方 | `openai` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Anthropic 官方 | `anthropic` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Google 官方 | `google` | ✅ | ⚠️ | ❌ | ❌ | first |\n| GitHub Copilot | `copilot` | ⚠️ token | ❌ 无额度概念 | ❌ | ❌ | first |\n| Codex plan | `codex` | ⚠️ | ⚠️ 用量窗口 | ❌ | ❌ | first |\n| Nvidia NIM | `nvidia` | ✅ | ⚠️ credits | ❌ | ❌ | first |\n| MiniMax | `minimax` | ✅ | ⚠️ | ❌ | ❌ | first |\n| DeepSeek | `deepseek` | ✅ | ✅ balance API | ❌ | ❌ | first |\n\n说明:官方直连来源普遍**不支持自动建 key**(用户的 API key 本身就是凭据,直接作为渠道 apiKey 录入即可)、也**没有分组倍率**(价格按官方定价表)。它们的 adapter 价值集中在「验证 + 查余额/用量窗口」,把 §3.2.4 UsageWindow 的官方用量 API 来源坐实。DeepSeek 有独立余额查询端点,可完整支持余额。Copilot 属订阅制、无 token 余额概念,只做验证 + 用量提示。\n\n**接入分层**(YAGNI:不强求一次做全,按价值排期):\n\n```text\nP0(本轮):new_api(完整:验证+余额+分组+建key+自动建渠道)\n —— 覆盖用户最主要的中转站场景\nP1:deepseek(验证+余额)、官方三家余额已在 Phase 4 完成 → 补 UsageWindow 对齐\nP2:nvidia / minimax(验证+credits/余额)\nP3:copilot / codex(验证+用量窗口提示,无余额)\n```\n\n每个来源的具体端点在实现时于 `adapter.go` 各 provider 文件内落地,本设计只固定接口契约与能力矩阵,避免文档与上游 API 变动强耦合。新增来源 = 实现 `ProviderAdapter` + 在能力矩阵登记,不改框架。\n\n**能力缺失与降级**:\n- adapter 任一能力返回 `ErrUnsupported` → 订阅中心对应字段回退为手动录入(余额手填、倍率手填),不报错、不阻塞接入。\n- adapter 验证失败/超时 → 订阅可仍以「手动模式」创建,仅失去自动刷新与自动建 key,其余流程(手填 baseURL+key 建渠道)不受影响。\n- 官方来源无「自动建 key」时,接入流程退化为 §8.4 标准快速添加:用户填 API key,adapter 只补验证与余额。\n- 所有自动刷新遵守 `SubscriptionAutoRefresh` 默认关闭;连续失败自动停用自动刷新并在驾驶舱「人工待办」提示。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3,\n \"providerTimePricing\": {\n \"deepseek\": {\n \"effectiveFrom\": \"2026-07-20T00:00:00+08:00\",\n \"timeZone\": \"Asia/Shanghai\",\n \"peakMultiplier\": 2,\n \"peakWindows\": [\n { \"start\": \"09:00\", \"end\": \"12:00\" },\n { \"start\": \"14:00\", \"end\": \"18:00\" }\n ]\n }\n }\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n`providerTimePricing.effectiveFrom` 使用 RFC3339;修改该值即可调整激活时间,设为空字符串则停用对应规则。\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**Phase 3A 状态(2026-07-09)**:低风险、不改真实路由语义的子集已完成并接入真实链路——`Manager.collectAll()` 新旧画像对比产出变更事件(`DetectProfileChanges`)、`ProfileChangelogStore`(环形内存 + SQLite,30 天机会性清理)、`EventHub` 内存 pub/sub、`GET /api/health-center/changelog`(历史)+ `GET /api/health-center/events`(WebSocket 实时推送)、`AutoDiscoveryRunner` 发布 `discovery_completed`/`auto_mapping_applied`、前端 `ProfileChangelogTimeline.vue` 接入健康中心页并支持断线自动重连。均为只读展示,未修改 `SmartRouter`/`EndpointAttemptPolicy` 调度逻辑。落地时顺带发现并修复一个真实缺口:浏览器原生 WebSocket 无法设自定义 header,为此给 `middleware.getAPIKey` 增加了 `Sec-WebSocket-Protocol` 鉴权回退(业界标准做法,仅新增一个 key 来源,不降低现有校验)。\n\n**Phase 3B(已全部完成,2026-07-09)**:模型自动映射、自动恢复探测、晋升/降级机制会直接改变真实调度决策,风险明显高于 3A,四个子项已分批全部完成。§12.2 P1.5 的 SQLite schema version / 版本化 migration / `OriginType`&`OriginTier` 旧配置 backfill / `killSwitch`+按 task class/channel disable **已于 2026-07-09 补强完成**(详见 §12.2 P1.5)。**Phase 3B-1(自动恢复探测补全)已于 2026-07-09 完成**:修复了 L2 探测子系统里此前从未打通的两个真实 bug——① `collectAll()` 每轮把新构造的画像整行 Upsert,会把 `ProbeWorker` 刚写入的 `LastProbeAt`/`ProbeSuccess`/`ConsecutiveProbeSuccess` 等字段清零,导致 6 小时探测冷却期形同虚设(修复:`carryForwardProbeFields` 在 Upsert 前搬运 Probe* 字段,不影响 L1 诊断字段的现有覆盖逻辑);② `extractRawAPIKey` 是返回 `KeyMask` 掩码值的占位 stub,探测请求实际带的是假 key(修复:新增 `APIKeyResolver` 回调 + `Manager.ResolveAPIKey`,通过遍历配置里对应渠道的 `APIKeys` 逐个计算 `KeyHashFromAPIKey` 比对来还原明文;resolver 未注入或未命中时 fail-open 跳过探测且不消耗预算)。同时补齐了 `degraded/limited → healthy` 的连续成功恢复路径(新增 `KeyEndpointProfile.ConsecutiveProbeSuccess` 计数器 + `HealthCheckConfig.ProbeRecoveryThreshold`,默认连续 2 次探测成功才恢复,避免单次探测噪声导致状态抖动;探测失败计数器清零)。**Phase 3B-3(StabilityTier 晋降级滞后)已于 2026-07-09 完成**(提交 664d3fff):在原本每轮从瞬时 1h 窗口重算的无记忆 `StabilityTier` 上加一层连续窗口滞后——新增 `KeyEndpointProfile.EffectiveStabilityTier`/`StabilityPendingTier`/`StabilityPendingStreak` 三字段 + 纯函数 `applyStabilityHysteresis`(对称阈值:连续 N 轮同值才切换生效档,`HealthCheckConfig.StabilityHysteresisWindows` 默认 2;首次画像直接采用,不等待阈值,避免新渠道卡在零值),`AggregateChannelProfile` 改读 `EffectiveStabilityTier`(空则回退 `StabilityTier`)。晋升/降级机制的数据模型仅限于 StabilityTier,不改变 QualityTier/SpeedTier/CostTier 语义。**Phase 3B-2(模型自动映射闭环)已于 2026-07-09 完成**(提交 0f02e988,随后修复一处 critical 集成 bug,提交 856372c4):新建 `ModelProfileStore`(SQLite,`model_profiles` 表)+ 扩展 `AutoDiscoveryRunner` 为每个发现模型写入 `ModelProfile`(仅 `AutoManaged` 渠道);新建 `ModelResolver` 实现 §5.4 五步逻辑(手动 `RedirectModel` 短路 → `GetModelProfiles` → `filterByCapabilityFloor` → `rankBySimilarity`);`scheduler` 包新增 `ModelSupportResolverFunc` hook(fail-open,未注入或返回不支持时严格回退到原 `ExplainModelSupport`);`endpoint_policy.go` 的 `resolveMappedModel`(此前是永不触发的死代码——`profile.ModelMapping` 从未被写入)现调用 `ModelResolver`;`handlers/common` 在 `RedirectModel` 之后应用 `MappedModel` 覆盖请求体,并按 `EchoMappedModel` 配置回显 `X-CCX-Mapped-Model`/`X-CCX-Original-Model`/`X-CCX-Mapping-Source` 响应头。三条件安全门控:`ModelMapping.AutoResolve==true`(**默认由死配置的 true 改为 false**)AND `RoutingMode ∈ {assist, auto}` AND 无 `KillSwitch`,三者任一不满足则所有新增代码路径行为与今天字节级一致;且仅 `AutoManaged` 渠道触发,手动配置渠道完全不受影响。对抗性复核发现并修复一个真实 critical bug:`scoreEndpointForKey` 用空 `channelUID` 计算 `EndpointUID` 并写入 `modelByUID`,而 `handlers` 层用真实 `channelUID` 查 `modelByUID`,两者哈希永不相等导致 `MappedModel` 在 key 级调度路径上完全查不到(提交 856372c4 修复:命中画像后改用 `profile.EndpointUID`,新增回归测试锁定该不变量)。至此 Phase 3B 四个子项(自动恢复探测、StabilityTier 滞后、模型自动映射、active filter 前置判定)全部完成;SLO regression 自动回滚仍是独立子系统,留待后续单独立项。\n\n**范围**:\n- [x] 运行时指标驱动画像实时更新(Phase 3A:`collectAll()` 每轮 diff + changelog + WebSocket 推送)\n- [x] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)—— Phase 3B-2(2026-07-09 完成,提交 0f02e988 + 856372c4)\n- [x] active model filter / context filter 支持自动映射前置判定 —— Phase 3B-2:`scheduler.ModelSupportResolverFunc` fail-open 接入 `select.go`\n- [x] 自动恢复探测(limited/dead → healthy)—— Phase 3B-1(2026-07-09 完成):`APIKeyResolver` + `carryForwardProbeFields` + `ConsecutiveProbeSuccess`/`ProbeRecoveryThreshold`\n- [x] 晋升/降级机制(连续成功→升级,连续失败→降级)—— Phase 3B-3(2026-07-09 完成,提交 664d3fff):`StabilityTier` 连续窗口滞后(`EffectiveStabilityTier`),暂不含 QualityTier/SpeedTier/CostTier\n- [x] WebSocket 推送画像变更事件(Phase 3A:`GET /api/health-center/events`)\n- [x] 前端画像变更历史/时间线(Phase 3A:`ProfileChangelogTimeline.vue`)\n\n**预估工期**:2-3 周(3A、3B-1、3B-2、3B-3 均已完成;advisor 与全局 auto 的 SLO 回滚均已补齐)\n\n> 状态校正(2026-07-17):上方 Phase 3B 历史说明中的“SLO regression 自动回滚待单独立项”已完成;advisor 回滚与全局 auto 上线闸门/自动降级是两个独立保护层。\n\n### Phase 4:高级特性\n\n**Phase 4 状态(2026-07-09,全部完成)**:8 项全部实现并接入真实链路,均遵循\"默认关闭 + 显式 opt-in\"安全守则。\n\n**范围**:\n- [x] 多维度标签系统扩展(用户自定义标签)——Item 1\n- [x] 成本报表:按用户/模型/渠道/key 统计真实有效成本——Item 2(新增 `proxyKeyMask` 身份贯穿链路:`middleware.GetProxyKeyMask` → `ChannelLog`/`RequestRecord` → SQLite `proxy_key_mask` 列,供 Item 4 复用)\n- [x] Trusted advisor SLO regression 自动回滚——Item 3(默认关闭;`SLORollbackConfig`,连续 degrading 窗口数默认 3,不做自动恢复以避免震荡)\n- [x] 渠道推荐:根据使用模式推荐新渠道——Item 4\n- [x] 批量渠道管理(导入/导出/模板)——Item 5\n- [x] 订阅中心 provider adapter:可选自动刷新余额/套餐状态——Item 6(默认关闭;仅 openai/anthropic/google 且显式填写 BillingAPIKey 的订阅生效,中转/公益渠道继续手动维护)\n- [x] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板——Item 7\n- [x] A/B 测试:低比例统计抽样双发——Item 8(默认关闭,默认 1% 抽样 + 每小时 60 次硬预算;主请求路径不受影响,影子请求在主响应返回后异步发起)\n\n**关键实现说明**:\n- Item 3/6/8 均在 `AutopilotRoutingConfig` 新增独立配置块(`SLORollback`/`SubscriptionAutoRefresh`/`ABTest`),`Validate()` 统一做负值/越界兜底,保持与既有字段一致的防御模式。\n- Item 4 直接复用 Item 2 的 `proxyKeyMask` 身份标记,未重新发明身份识别机制。\n- **全局 auto 安全闸门(2026-07-17)**:请求终态按 15 分钟窗口无偏聚合,详细 trace 继续抽样;管理 API 只在 shadow/assist 已积累至少 500 个有效请求、24 小时连续观测且成功率/fallback/fail-open/p95 均达标时允许切换 `auto`。后台连续检测三个已完成 auto 窗口,相对最近七天安全模式基线持续恶化时,持久化降级到 `assist` 并记录 `autopilot_auto_safety_events`,不自动恢复。\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- ~~SQLite schema version 和幂等 migration。~~ **已完成**:`ensureSchemaVersion` 基于 `PRAGMA user_version`,当前 7 张表定版为 v1 基线;库版本高于代码版本时返回 error(fail-closed,不做任何写操作),交给下游 fail-open 分支接管。\n- ~~旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。~~ **已完成**:`ensureOriginBackfill` 只补 `\"unknown\"`,不做任何基于 URL/名称的猜测推断;已接线到 `Profiler.DeriveEndpointProfile`,`BreakTieByOriginTier` 现在能吃到真实数据(此前一直是 fallback 的 `unknown`)。\n- ~~profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。~~ **已确认满足并测试锁定**:`main.go` 现有结构本就是 fail-open(`NewProfileStore`/`NewManager` 出错只打日志、`autopilotManager` 保持 `nil`,下游全部有 `nil` 守卫),本次新增 `schema_migration_test.go` 用测试锁定这条不变量。\n- ~~全局 `killSwitch`、按 task class disable、按 channel disable。~~ **已完成**:新增 `AutopilotRoutingConfig.DisabledTaskClasses`/`DisabledChannelUIDs`;`CandidateFilterFor`/`executeFilter`/`BuildPlan` 三处一致接入,`smart_router_invariant_test.go` 覆盖\"task class 禁用后回退默认调度\"\"channel 禁用后不出现在候选里\"\"dry-run 与真实路径行为一致\"三条不变量。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。**部分完成**:`ReleaseController.EvaluateAndApplyRegression` 已实现三窗口回归检测和 `SafetyOverride` 降级机制;`RoutingModeActive` 已加入配置枚举和 `IsAutopilotActive`。\n\n#### P1.6 Trace v2 契约与灰度发布\n\n> 对应实施计划:`docs/superpowers/plans/2026-07-23-autopilot-trace-rollout.md`\n\n**Trace v2 核心变更:**\n\n- `RoutingDecisionTrace` 增加 `SchemaVersion`、`TraceRevision`、`RequestCorrelationId`、`ReleaseID`、`PolicyFingerprint`、`TargetMode`/`EffectiveMode`/`Cohort`、`SchedulerDecision`、`EndpointAttempts` 字段。\n- 新增 `TraceDetailV2`、`TraceSummary`、`SchedulerDecisionSummary`、`EndpointAttemptSummary` DTO;使用显式 DTO 落盘而非直接 JSON 化运行时结构。\n- `ComparisonStatus` 三态枚举(`matched`/`mismatched`/`uncompared`)替代裸 `bool`。\n- `SanitizeForPersistence` 与 `SanitizeForResponse` 双边界脱敏;`GenerateTraceUIDv2` 使用 `crypto/rand` 碰撞安全生成。\n- `RoutingReleaseSnapshot` 不可变发布快照,入口冻结后续只读。\n\n**SQLite v6 迁移:**\n\n- trace 表新增列:`schema_version`、`trace_revision`、`request_correlation_id`、`release_id`、`policy_fingerprint`、`persistence_class`、`details_json`。\n- 窗口表重建主键:增加 `release_id`/`policy_fingerprint`/`cohort` 维度,新增 `compared_count`/`matched_count`/`mismatch_count`/`uncompared_count` 全量比较计数。\n- safety event 表增加 `release_id`/`policy_fingerprint` 字段。\n\n**Autopilot 单一运行态(2026-07-28):**\n\n顶层 `off → shadow → assist → auto → active` 灰度状态机已经废止。系统启动后固定执行 Autopilot 自动路由:应用硬约束过滤并按评分重排,候选全部被过滤时 fail-open。旧配置中的 `mode` 值会在加载时统一归一化为 `auto`,不再提供模式切换 API 或管理界面。\n\n`AUTOPILOT_KILL_SWITCH` 与配置中的 `killSwitch` 是唯一的全局急停入口;启用后不注入 SmartRouter 和 EndpointAttemptPolicy,恢复调度器默认行为。管理端 dry-run 仍作为独立诊断能力保留,不属于运行模式。\n\n发布批次、策略指纹和 cohort 字段继续保留在 Trace 契约中,用于兼容历史数据及诊断聚合;`ReleaseController`、readiness 样本门槛、自动降级/恢复和灰度模式切换代码均已删除。\n\n**只读 Trace API:**\n\n| 接口 | 返回 | 约束 |\n|---|---|---|\n| `GET /api/autopilot/traces` | `TraceSummary[]` | 游标分页、release/cohort/mode 过滤、partial 标记 |\n| `GET /api/autopilot/traces/:traceUid` | `TraceDetailV2` | 404=未找到/已过期/未采样;503=存储不可用 |\n| `GET /api/autopilot/traces/stats` | `TraceStatsResponse` | 三态比较统计、Mode/TaskClass 分布 |\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | advisor/local-model 保留独立 shadow 门槛;全局 Kill Switch 可立即停止 Autopilot |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "c22adca0bcb4e0a444c202bd6df10f8250836ae8": {"t": 1785759330.885534, "n": 167476, "h": "a3a8b09c76771273", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.2.3 同订阅多 Key:能力共享与配额负载均衡\n\n**问题**:官方订阅 plan(Claude Max、Codex plan 等)允许用户持有多个 key,或购买多份相同套餐。若每个 endpoint 独立探测与配置,探测预算被浪费、配置重复;请求量大时还需要在同 plan 的多个 key 之间按官方 RPM 限速自动负载均衡,而不是打满第一个 key 才溢出到下一个。\n\n**能力检测去重(同一套餐只需一套能力检测和配置)**:\n\n```text\nSubscriptionUID 相同的 endpoint 共享一份「订阅级能力画像」:\n 模型列表 / vision / tool / reasoning 支持 / 协议兼容开关 → 只探测一次,\n 存在 SubscriptionProfile.SharedCapability,endpoint 画像以引用方式继承。\n\n仍然 per-endpoint 维护的:健康状态、延迟、熔断、用量窗口\n (key 可能被单独封禁/限速,能力相同不代表运行态相同)\n\n继承失效条件:某 key 实测模型列表哈希与订阅级不一致(GroupChangeDetector 信号)\n → 该 key 脱离继承、独立探测,并在 UI 标记「与套餐能力不一致」警告。\n```\n\n**配额负载均衡(Phase 1 shadow 展示,Phase 2 生效)**:\n\n```text\n每 key 维护配额余量视图:\n remainingRPM = officialRPM − 当前窗口已用\n officialRPM 来源优先级:内置 manifest > 用户配置 > RateLimitDiscoverer 建议\n\n同订阅多 key 时按「剩余余量加权」分配请求(余量大者优先、\n窗口最早重置者兜底),替代顺序打满:\n 仅当渠道 autoManaged 且同一 SubscriptionUID 下 key 数 > 1 时启用;\n 否则保持现有 sequential/random key 顺序策略不变。\n\n某 key 的窗口配额耗尽 → 临时降权并切到同订阅其他 key,\n不熔断整个渠道(这是配额耗尽,不是故障)。\n```\n\n### 3.2.4 订阅用量窗口 (Usage Windows)\n\n部分官方 plan 提供 5h / 日 / 周 / 月等窗口的用量查询与限制(如 Claude 订阅的 5h 滚动窗口 + 周配额)。在 key 的位置展示这些窗口,帮助用户掌握使用情况:\n\n```go\ntype UsageWindow struct {\n Window string `json:\"window\"` // \"5h\" | \"day\" | \"week\" | \"month\"\n Used float64 `json:\"used\"` // 已用量\n Limit float64 `json:\"limit\"` // 上限,0 = 未知\n Unit string `json:\"unit\"` // requests | tokens | credits | percent\n ResetsAt time.Time `json:\"resetsAt\"` // 窗口重置时间\n Source string `json:\"source\"` // official_api | response_header | local_metering\n FetchedAt time.Time `json:\"fetchedAt\"`\n}\n// KeyEndpointProfile 与 SubscriptionProfile 均增加 UsageWindows []UsageWindow\n```\n\n数据来源优先级:\n\n```text\n1. 官方用量 API:有 provider adapter 的 plan 主动拉取(§Phase 4 订阅 adapter 的可提前子集)\n2. 响应头:anthropic-ratelimit-* / x-ratelimit-* 等(复用 RateLimitDiscoverer 的信号 tap)\n3. 本地计量兜底:CCX 按 key 累计请求数/token(误差来自窗口起点对齐,标注 Source=local_metering)\n```\n\nUI 要求:key 行内显示主窗口(5h)用量进度条,悬浮展开全部窗口;用量 >80% 黄色预警、超限红色并显示预计重置时间。调度联动(Phase 2):窗口余量进入配额负载均衡权重,窗口耗尽的 key 临时降权而非熔断。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n#### 4.2.1 内置模型清单 (Builtin Models Manifest)\n\n**问题**:部分官方订阅 plan 的入口没有正确的 models 接口——要么不存在(404)、要么需要额外权限、要么返回与订阅实际可用模型不符的列表(典型:Claude 订阅 OAuth 入口、Codex plan 入口、部分官方企业网关)。对这些 baseURL 做模型发现会失败或产出错误画像。\n\n**方案**:CCX 内置「已知官方 baseURL → 模型清单」映射表,命中时直接返回内置内容,完全跳过上游 models 接口:\n\n```go\n// backend-go/internal/config/builtin_models_manifest.go(生成式,随模型注册表更新)\ntype BuiltinModelsManifest struct {\n BaseURLPattern string `json:\"baseUrlPattern\"` // 精确 host 或前缀匹配,如 \"api.anthropic.com\"\n ServiceType string `json:\"serviceType\"` // messages/responses/…\n PlanHint string `json:\"planHint,omitempty\"` // 订阅类型提示(如 \"claude_subscription\")\n ModelIDs []string `json:\"modelIds\"` // 该入口实际可用的模型清单\n DisableProbe bool `json:\"disableProbe\"` // true 时 Discovery 跳过 GET /v1/models\n}\n```\n\n**两个生效位置**:\n\n```text\n1. Discovery(§4.2 流程第 2 步之前):\n baseURL 命中 manifest 且 DisableProbe=true\n → 跳过 GET /v1/models,直接用 ModelIDs 生成 ModelProfile[]\n → ProfileSource = \"builtin_manifest\",不再因 models 接口失败而误判 misconfigured\n\n2. /v1/models 代理响应(现有 Models API):\n 客户端向 CCX 请求 /v1/models 时,聚合结果中该渠道的贡献\n 直接取内置清单,不向上游发起 models 请求\n (渠道显式配置了 supportedModels 时仍以用户配置优先)\n```\n\n**维护与覆盖顺序**:\n\n```text\n渠道显式 supportedModels(用户手动) > 内置 manifest > 上游 models 接口实探 > 内置候选模型回退\n```\n\nmanifest 与模型注册表同源生成(`generated_model_registry.go` 同级),新模型发布时随注册表一起更新;HealthAnalyzer 对命中 manifest 的 endpoint 不将「models 接口 404/403」计入 misconfigured 信号。\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n MappedEffort EffortLevel // 自动映射的 effort 级别(空=未决定)\n MappedEffortDecided bool // effort 是否由 Autopilot 决定\n EndpointUID string // 稳定 endpoint ID\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n\n // ResolvedModelByEndpointUID(旧 API):仅返回模型名,向后兼容。\n // ResolvedTargetByEndpointUID(新 API):返回 model + effort 原子决策。\n // 两者从同一份 targetByUID 派生,避免两个真值源。\n ResolvedModelByEndpointUID func(endpointUID string) string\n ResolvedTargetByEndpointUID func(endpointUID string) *ResolvedRouteTarget\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20;assist 下 dead/misconfigured 额外 -100 并保留在队尾\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法细分时进入 `general`;有规范 Knowledge 基准则使用其软评分,缺少直接证据时仍取 0.5 中性值。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n**手动 L3 第一版状态(2026-07-15)**:已实现 `pq-v1-20260715` 固定 JSON canary,覆盖\nMessages、Chat Completions、Responses 与 Gemini 上游协议。调用方只能指定 `endpointUid`、\n`modelId` 和 1~3 次重复数,不能注入 prompt。评分仍按 40% 完整性、30% 语义正确性、\n15% 严格 JSON 格式、15% 延迟执行;第一版使用非流式完整响应延迟作为首 token 延迟代理,\n后续引入流式采样时再替换该维度。单次有效样本 confidence=0.6,最多三次提升到 0.8;\n失败样本按 0 分计入均值,全部失败不写画像。\n\n安全边界:仅管理 API 手动触发,不启动自动 worker;当前进程按 UTC 日设置 12 次硬预算,\n请求前原子预留,单次 90 秒超时、响应体上限 64 KiB。同一 endpoint×model 不并发执行;\n单次请求最多允许 2048 个输出 token,以容纳推理模型的隐藏思考并限制最坏额度消耗。\n对声明可控推理的模型固定采用最低可用 effort;Claude 兼容端点若只暴露\n`reasoning_content` 而未声明 thinking 模式,则与 capability-test 一致显式关闭 thinking。\n原始模型输出和明文 Key 既不落盘也不返回,只保存分数、canary 版本、置信度和脱敏判定。\n`user_feedback` 仍高于 `probe`,自动发现重建 ModelProfile 时必须保留既有质量证据。\n跨进程共享预算与 LLM-as-judge/语义向量评分仍属于后续阶段。\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设 + 自定义\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n| `custom` | 用户自定义 | 用户自定义 | 高级用户微调,范围 0.0 ~ 3.0 |\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法细分的通用任务;缺少基准证据时中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general;有规范 Knowledge 基准时使用其软评分,缺少直接证据时取 0.5 中性。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? canonicalBenchmark[model][domain] * providerQualityFactor\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 规范模型基准 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n#### 5.7.4 规范模型能力基准\n\n`shared/model-registry/ccx_model_registry.json` 的 `benchmarkProfiles` 独立保存规范模型能力上界,不与 `upstreamCapabilities` 混合。后者仍是 context、vision、tool calls 等硬能力的事实源;benchmark 只参与 Autopilot 软评分和 trace 解释,绝不改变 `supportedModels` 或 CapabilityFloor。\n\n```text\nEffectiveCapability[domain]\n = CanonicalCeiling[model, domain]\n × ProviderQualityFactor[endpoint]\n\nProviderQualityFactor = 1 - confidence × (1 - providerQualityScore)\n无渠道质量证据或 confidence<0.5 时 factor=1;可信证据只能从规范模型上界向下折算。\n```\n\n基准数据必须保留领域向量、来源、核验日期、lane、共享结果数和可比类别覆盖率,不能只保存站点 Overall。当前 BenchLM 接入为 `provisional`:Claude Opus 4.8、GPT-5.6 Terra、GPT-5.6 Sol 各有 30 个共享结果,8 类中 5 类可比,核验日期为 2026-07-13。\n\n领域映射:Knowledge → `general`,Math → `reasoning`,Coding → `coding` / `code_review`,Agentic → `agentic`,Multimodal → `aesthetics_ui`(仅弱代理,解释置信度减半)。Writing / Translation 没有直接类别证据时继续回退家族种子或 0.5。BenchLM 的 cohort 归一化、缺失项插补和外部校准决定了 Overall 不是绝对能力值,因此路由只消费可映射的原始类别分。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n#### 5.8.4 联合决策的现状实现(model + effort 原子化)\n\n> 状态说明(2026-07-26):本节描述的联合决策链路已实现并接入真实请求路径(关联计划 [2026-07-26-autopilot-model-effort-joint-routing.md](../superpowers/plans/2026-07-26-autopilot-model-effort-joint-routing.md) 的 Task 1~7)。**渠道级手工 `modelMapping` / `reasoningMapping` 表目前仍然存在且仍然生效**,退役是该计划的 Task 8,尚未执行;手工表当前的优先级关系见下文。\n\n**决策对象。** 自动路由的返回值不再是裸模型名,而是 `autopilot.ResolvedRouteTarget`(`backend-go/internal/autopilot/route_target.go`):\n\n```go\ntype ResolvedRouteTarget struct {\n Model string // 目标模型 ID\n Effort EffortLevel // 目标思考档位;空串表示不改写\n EffortDecided bool // true 表示 effort 由 Autopilot 决定;false 为 passthrough\n Reason string // 决策原因,用于 Trace 与日志\n}\n```\n\n`EffortDecided=false` 且 `Effort` 为空是合法的 fail-open 结果:模型可能被改写,但思考参数保持原样。\n\n**决策发生的位置。** `autopilot/endpoint_policy.go` 的 `resolveMappedModel()` 是统一入口,内部分两路:`resolveManualMapping()` 处理渠道级显式 `ModelMapping`(仍是当前优先级更高的一路,返回的 `ResolvedRouteTarget.EffortDecided` 恒为 `false`,即手工映射只改模型不改档位);`resolveAutoModel()` 调用 `ModelResolver` 做自动模型解析,并在 `model_resolver.go` 的 `rankEligibleModels()` 中按 `SupportedEffortLevels` × `PerTaskClass` 展开 `(model, effort)` 候选、应用 `EffortQualityBonus` 与 anti-effort-inflation 反膨胀 tiebreak(同质量档优先选低 effort)、并按 `EffortFloor` 过滤掉低于任务下界的候选。产出的 `ResolvedRouteTarget` 经 `buildResolvedTargetLookup()` 挂到 `EndpointAttemptPolicy.ResolvedTargetByEndpointUID`,与旧的 `ResolvedModelByEndpointUID`(仅模型,向后兼容)并列。\n\n**决策如何到达上游请求。** `handlers/common/upstream_failover.go` 的 `TryUpstreamWithAllKeys` 在为每个 endpoint 尝试构建请求前,通过 `ResolvedTargetByEndpointUID` 取得 `*ResolvedRouteTarget`,交给 `atomicModelEffortRewrite()` 做一次原子改写:先用 `sjson` 改写 `model` 字段;若 `EffortDecided=true` 且 `Effort` 非空,再检查目标模型的 `ThinkingMode`——`adaptive_only` 的模型跳过思考参数注入(模型自身决定思考深度,守卫逻辑对齐 `providers/claude.go` 的 `applyClaudeReasoningEffort`),否则调用 `config.ApplyReasoningParamStyle()` 写入。模型改写与档位改写共用一次函数调用,任一步 `sjson`/`json` 处理失败即整体放弃、保持原始 body,不会出现\"模型改了、档位没改\"的中间态。\n\n**各协议的注入形态。** `config.ApplyReasoningParamStyle(req, style, effort)`(`config/config_utils.go`)按 `upstream.ReasoningParamStyle` 三态分支:\n\n```text\nstyle=\"thinking\" → Claude 派系:写 thinking.type=enabled + thinking.effort=;\n effort 为 off/none 时写 thinking.type=disabled;同时清理互斥字段\n reasoning / reasoning_effort / thinking.budget_tokens\nstyle=\"reasoning_effort\" → OpenAI Chat 派系:写顶层 reasoning_effort 字段,清理 reasoning\nstyle=\"\" / 默认 → Responses 派系:写 reasoning.effort 对象形态\n```\n\n**Gemini 派系是尚未接通的缺口。** 请求侧提取(`extractGeminiEffortExplicit()`,`handlers/common/reasoning_log.go`)已经能读取 `generationConfig.thinkingConfig.thinkingLevel` 并识别显式 `none`(`thinkingBudget=0`),这条读路径用于日志与 `RequestProfile.ClientEffort` 提取。但写路径——把联合决策的 `target.Effort` 写回 Gemini 请求体的 `thinkingConfig.thinkingLevel`——目前代码中不存在:`atomicModelEffortRewrite()` 唯一的写出口是 `ApplyReasoningParamStyle()`,它只覆盖 Claude/Chat/Responses 三种形态,没有 Gemini 分支;`handlers/gemini/handler.go` 的 `buildProviderRequest()` 也没有调用任何 effort 注入函数。也就是说,Gemini 渠道当前仍会经过 endpoint 级模型改写(`atomicModelEffortRewrite` 会改写 `model` 字段本身),但联合决策选中的思考档位不会落到 Gemini 请求体上,效果等同于 fail-open passthrough。此项在原计划里归属 Task 5 的\"Gemini 派系注入路径\",尚未实现。\n\n**可观测性字段。** 原子改写点会向 `gin.Context` 写入两个字段供 ChannelLog 使用:`effortDecisionSource`(`autopilot` 表示由联合决策产出,`passthrough` 表示未改写或手工映射路径)、`effortClampedByClient`(当客户端显式声明的档位低于 Autopilot 选择的档位时置 `true`,由 `isEffortClampedByClient()` 基于 `EffortLevelOrdinal` 比较得出)。\n\n**手工映射表的现状。** `resolveMappedModel()` 当前的分支顺序是\"显式 `ModelMapping` 优先,自动决策兜底\"——与原计划 Task 7 描述的\"反转优先级\"目标相反的方向仍是默认值;反转开关已作为可配置项落地(提交 `d15e776e`),但默认状态未切换,`ModelMapping` / `ReasoningMapping` 两张表本身尚未删除,`config.ResolveReasoningEffort()`(`config/config_utils.go`)与 provider 层(如 `providers/claude.go`)仍会读取 `ReasoningMapping` 独立生效。表的整体退役是 Task 8 的范围,未开始。\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**请求意图边界(ModelRoutingPolicy)**\n\n跨模型替代不是所有模型的默认能力,只对白名单客户端入口开放:\n\n```text\nmessages:\n fable / opus / sonnet / haiku 及对应 claude-* 完整模型名 → adaptive\nresponses:\n gpt-5.6* / gpt-5.5 / gpt-5.4* / codex-auto-review → adaptive\n其他协议或模型:\n exact-only,只能在探测到相同规范化模型 ID 的 endpoint 之间调度\n```\n\n`adaptive` 允许在满足 Capability Floor 后选择 GLM、MiMo 等其他模型族;`exact-only` 禁止跨模型替代。用户显式配置的 `modelMapping` 仍优先于该策略。调度预览和 endpoint 实际解析必须调用同一分类函数,避免诊断结果与真实请求分叉。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ModelProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选优\n // 上下文窗口只负责准入,不对更大的窗口扣分。\n // 排序优先级:\n // a. 模型质量档越高越优先\n // b. provider 已确认的同档模型能力顺序\n // c. 带置信度折算的供应商实测质量\n // d. 探测延迟\n // e. 同渠道/provider 的模型相对消耗倍率\n // 倍率相同或缺失时再比较公开模型成本\n // f. 同模型族与 model ID 仅作为确定性兜底\n best := rankEligibleModels(eligible, requestModel)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n同渠道套餐倍率与公开 USD/Token 价格是不同单位,禁止直接混算。模型解析器先比较 provider\n提供的相对消耗倍率;只有双方倍率相同,或双方均无倍率时,才回退到统一输入/输出规模下的\n公开价格。优云智算当前内置倍率如下(数字越小越省,`glm-5.2` 的 2 次为限时值):\n\n| 模型 | 每次调用套餐消耗 |\n|------|-----------------:|\n| `glm-5.2` | 2 |\n| `glm-5.1` | 6 |\n| `MiniMax-M2.7` | 2 |\n| `kimi-k2.6` | 5 |\n| `deepseek-ai/DeepSeek-V3.2` | 1 |\n| `deepseek-v4-flash` | 1 |\n\nprovider 可以额外声明同一质量档内已确认的模型能力顺序,但只有该档全部候选都命中该元数据\n时才比较,避免把未知模型误判为弱模型,也保证排序不依赖候选输入顺序。优云模板当前记录\n`glm-5.1 > kimi-k2.6`,因此前者即使消耗 6 次,也会在后者的 5 次成本证据之前胜出。\n`glm-5.2` 即使消耗 2 次,也仍会在质量档高于 DeepSeek 候选时优先;V3.2 与 V4 Flash\n同为 1 次时,再由公开价格及最终确定性规则区分。静态 `modelMapping` 仅保留为用户显式覆盖,\n不承担自动替代策略。\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - autoManaged 的空 supportedModels 不表示支持全部,必须由 endpoint ModelProfile 裁决\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n当 ModelResolver 已正常初始化且路由处于 `assist/auto + autoResolve=true` 时,autoManaged 渠道的画像拒绝是权威结果,调度器不得再 fail-open 到空 `supportedModels` 的兼容语义。Resolver 不可用、开关关闭或 `off/shadow` 时仍保留原有 fail-open,保证急停和降级路径不依赖画像库。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n### 6.9 渠道兼容性统一自学习\n\n渠道级兼容性配置不应要求用户先踩坑、再去翻设置面板找开关。这一节描述如何把这些开关从\n「静态布尔」转为「运行时学习到的能力事实」。\n\n**优先级合成**(`config.resolveCompatSwitch`)\n\n```text\n最终生效值 = 学习结论 ?? 历史种子(14 天内) ?? 静态默认\n```\n\n六个兼容性字段(`NormalizeNonstandardChatRoles`、`StripImageGenerationTool`、\n`CodexNativeToolPassthrough`、`StripEmptyTextBlocks`、`PassbackReasoningContent`、\n`PassbackThinkingBlocks`)已从 `UpstreamConfig`/`UpstreamUpdate` 整体删除:管理面板不提供编辑\n入口,渠道更新接口不接受手工写入,完全交由运行时自动决策。不再有「用户当前显式设置」这一档——\n历史手工值不代表可信的长期事实,可能当初就设错,也可能上游后来有了新情况已不再适用,因此不作为\n可以无限期压过学习的强制覆盖,只作为学习结论缺失时的一次性低置信度提示。\n\n学习结论通过 `UpstreamConfig.LearnedCompatTraits`(运行时字段,`json:\"-\"`,不落盘)传递:\nfailover 在发送前按 渠道-Key-模型 查询 `ChannelCompatCache`,把结论注入当次请求专用的\nupstream 副本,provider 构造请求时读取。\n\n**历史手工配置降级为种子**(`migrateManualCompatSwitchesToSeeds`):老用户配置文件里的六个字段\n已从结构体删除,只能在启动时从磁盘原始 JSON 里读到历史手工值。迁移把这些值降级为\n`UpstreamConfig.CompatSeeds`(`map[string]CompatSeedEntry`,带 `ExpiresAt`):\n\n- 种子带 **14 天有效期**(`CompatSeedTTL`),过期后自动失效、不再参与判断,回落学习结论或静态\n 默认重新判断。种子与学习记忆分开存储且 TTL 不同(种子 14 天 vs 学习记忆 24 小时),因为二者\n 语义不同:种子是\"曾经的一个信号\",学习记忆是\"渠道-Key-模型级的当前能力事实\"。\n- 迁移保留 `false` 值:用户「显式关闭」同样是他曾观察到的行为证据。\n- 迁移当下不改变可感知行为(学习缺失时按种子生效),但学到真实结论后立即让位,种子过期后也\n 自动退场。\n- 幂等:已有种子的渠道跳过。\n- 自动托管渠道在 `RuntimeUpstreamForAutoManagedProvider` 里连种子一起清空,其兼容性完全由\n 厂商原生默认值加运行时学习决定。已知厂商协议事实(如 GLM 的 OpenAI 兼容协议要求\n `passbackReasoningContent`)不走种子这条给\"可能出错的历史手工值\"用的临时通道,而是在对应\n accessor 里按 `ProviderID`/`ServiceType` 直接计算为静态默认(`shouldPassbackReasoningContentByDefault`\n 之类),因为这是不会过期的协议事实而非猜测。\n\n**记忆载体**:`config.ChannelCompatCache`,落盘 `.config/channel_compat.json`,\n键 `channelUID:keyHash:model`,TTL 24h(与 `DeprecatedParamCache`/`SystemHeaderFilterCache` 一致)。\n`Record` 仅在结论新增或翻转时返回 `true`,调用方据此决定是否重试,防止记忆生效后死循环。\n\n**三条学习路径**(按上游信号强度区分,不可一概而论)\n\n| 信号类型 | 兼容项 | 学习方式 |\n| --- | --- | --- |\n| 硬 400/422 + 可正则匹配 | developer role 降级、Codex 专属工具剥离、历史 thinking 回传、图片工具剥离 | 错误驱动:`CompatTraitFromError` 识别 → 记忆 → 同 Key 立即重试 |\n| 无报错,仅响应形态差异 | `passbackReasoningContent`、`stripEmptyTextBlocks` | 探针驱动:首次遇到该组合时 singleflight 后台探测一次 |\n| 厂商文档已公布约束 | Kimi 固定值参数、`tool_choice`、`thinking` | 前置规避:`ApplyKnownParamConstraints` 发送前直接改写,无需失败一次 |\n\n**分层职责**:`upstream_failover` 负责识别信号、写缓存、编排重试、把结论注入 `upstreamCopy`;\n各 provider 在构造上游请求时读取合成值并执行改写。这样 developer role 降级发生在\nResponses→Chat 转换**之后**的 `reqMap` 上,避开「failover 层拿到的是 Responses `input` 而非\nChat `messages`」这个陷阱。\n\n**防误判约束**(学错并持久化比不学更糟)\n\n- 双条件门控:正则命中之外,还要求请求侧确实携带对应结构(`BodyHasDeveloperRole` 等)\n- 单次重试上限:`Record` 返回 `false` 即不再重试\n- 不跨维度污染:结论不外溢到同渠道其他 Key/模型\n- 仅 400/422 参与学习;429/5xx/超时属容量问题,不是能力问题\n- fail-open:缓存损坏、探测失败一律退化为静态默认,不阻断代理请求\n\n**图片工具归因修正**:图片生成受限时,原实现直接拉黑 (Key,模型)。现改为先学习\n「需剥离图片工具」并重试修复请求,只有剥离后仍失败才回落拉黑——从「惩罚渠道」改为「先修复请求」。\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\nGET /api/health-center/provider-quality/budget → 查看本进程手动 L3 每日预算\nPOST /api/health-center/provider-quality/probe → 对指定 endpoint×model 执行固定质量 canary\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n#### 8.0.1 实现现状与演进方向(2026-07-09 核实)\n\n四界面在 **Web 前端(`frontend/src`)已大面积落地**,不是纯文档:\n\n| 界面 | Web 实现 | Desktop 实现 |\n|------|----------|--------------|\n| 健康中心 | ✅ `HealthCenterView.vue` + `HealthCenterStats`/`HealthChannelTable`/`HealthChannelDetail`/`ProfileChangelogTimeline`,路由 `/health` | ❌ 无 |\n| 订阅中心 | ✅ `SubscriptionsView.vue` + `SubscriptionPlanTable`,CRUD + 刷新余额,路由 `/subscriptions` | ⚠️ 独立旧实现 `subscriptions/SubscriptionTab.vue`(与 Web 两套代码) |\n| 渠道中心 | ✅ `ChannelOrchestration.vue`(六态 `ChannelHealthBadge`、来源 chips、`autoManaged` 图标齐备) | ❌ 未同步 |\n| 管理面板 | ⚠️ `AutopilotModePanel.vue` 仅暴露 mode/killSwitch/costPreference | ❌ 无 |\n| 驾驶舱 | ✅ `CockpitView.vue`(观测型总览:健康统计+余额汇总+渠道推荐) | ⚠️ `cockpit` tab 渲染的是会话 Dashboard,职责不同 |\n\n**与本设计意图的关系**(对齐用户「自动调度为主、废旧手动驾驶舱」的诉求):\n- **旧的「手动调度驾驶舱」在 Web 端已不存在**。`CockpitView` 已是 §8.7 定义的观测型总览,只回答健康/省钱/风险/待办四问题,不做渠道配置编辑,符合「程序自动选、人只观测+有限干预」的方向。无需再废除,只需继续收敛为纯观测。\n- **统一提交入口已存在**(§8.4 快速添加 + §8.5 订阅中心),new-api 集成(§8.5.1)在此之上把「填 baseURL+key」进一步自动化。\n\n**前端演进待办**(本设计新增/明确):\n1. **补 `ManualRoutingIntent` 前端**:这是用户要的「有限偏向性干预」,后端已有、两个前端均未实现(§8.3 试用意图入口尚缺)。\n2. **Desktop 前端对齐 Web**:健康中心/渠道自动托管/管理面板/观测型驾驶舱在 Desktop 几乎空白,需同步或复用 Web 组件,避免长期维护两套。\n3. **管理面板补字段**:暴露 `l2ProbeEnabled`、探测预算、new-api/多来源订阅 adapter 开关(§8.5.2)等,当前只有 3 个字段。\n4. **订阅中心接 §8.5.1/§8.5.2**:新增「new-api」订阅类型与 Provider Adapter 能力矩阵驱动的动态表单。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.5.1 new-api 订阅集成(统一账号接入)\n\n**目标**:用户在订阅中心手填一个 new-api(及其 fork)账号的「系统访问令牌」即可,CCX 自动用该令牌创建代理专用 key、拉取分组倍率、查询额度余额,并把「站点 baseURL + 新建 key」自动落成一个上游渠道,交给 Autopilot 探测与调度。\n\n**范围约束**:\n- 只使用 new-api 用户在「个人设置」生成的 **系统访问令牌(access_token)** 一种凭据;令牌用于 API 调用的身份验证,属敏感数据,须妥善加密存储。\n- **不做** cookie 登录 + 2FA + 导出后台渠道明文 key 的管理员迁移路径(all-api-hub 的 newApiSession 能力),本设计不涉及。\n- new-api 归入 `OriginType=relay`、`OriginTier=second`(中转站),信任等级不因自动化提升;实时质量仍由运行画像决定。\n- 认证令牌像其他订阅凭据一样由用户手动录入,CCX 不代替用户在 new-api 侧注册或登录。\n\n**认证与凭据模型**:\n\nnew-api family 的管理接口鉴权(依据 all-api-hub 传输层实现):\n\n```text\nAuthorization: Bearer # 系统访问令牌;raw 模式可去掉 Bearer 前缀\nNew-API-User: # 当前用户 ID(必带)\nUser-id: # fork 兼容回退(Veloera/voapi/Super-API 等)\nContent-Type: application/json\n```\n\n统一响应信封:`{ \"success\": bool, \"data\": T, \"message\": string }`;解析时先判 `success`,再取 `data`。\n\n**SubscriptionProfile 扩展**:\n\n在 §3.2.2 的 `SubscriptionProfile` 基础上,为可自动接入的订阅新增 provider 集成字段(沿用「默认关闭 + 显式 opt-in」守则):\n\n```go\n// SubscriptionProfile 新增字段(复用 Phase 4 Item 6 的 SubscriptionAutoRefresh 开关)\ntype SubscriptionIntegration struct {\n Kind string `json:\"kind\"` // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | ...\n BaseURL string `json:\"baseUrl\"` // new-api 站点地址(同时作为新建渠道的上游 baseURL)\n AccessToken string `json:\"-\"` // 系统访问令牌;加密存储,不出 API 响应\n UserID string `json:\"userId\"` // New-API-User 头所需\n AuthTokenMode string `json:\"authTokenMode,omitempty\"` // \"bearer\"(默认) | \"raw\"\n // 自动建 key 的模板\n ProvisionKeyName string `json:\"provisionKeyName,omitempty\"` // 默认 \"ccx-autopilot\"\n ProvisionGroup string `json:\"provisionGroup,omitempty\"` // 建 key 时指定分组,空=默认分组\n ProvisionModels []string `json:\"provisionModels,omitempty\"` // model_limits,空=不限制\n // 自动同步产物(回填,只读展示)\n ProvisionedTokenID int `json:\"provisionedTokenId,omitempty\"`\n LinkedChannelUID string `json:\"linkedChannelUid,omitempty\"`\n}\n```\n\n`AccessToken` 必须与现有敏感字段一样走加密存储,序列化到前端时脱敏(仅显示尾部若干位)。\n\n**new-api 接口清单**(本集成实际调用的子集,路径以主线 new-api 为准,字段名保持与上游一致):\n\n| 用途 | 方法 | 路径 | 请求体 | 响应关键字段 |\n|------|------|------|--------|--------------|\n| 校验令牌 + 查用户/余额 | GET | `/api/user/self` | — | `data.id`、`data.username`、`data.quota`(剩余额度)、`data.used_quota` |\n| 拉分组倍率 | GET | `/api/user/self/groups` | — | `data: { : { desc, ratio } }` |\n| 站点全部分组 | GET | `/api/group` | — | `data: string[]` |\n| 账号可用模型 | GET | `/api/user/models` | — | `data: string[]` |\n| 创建代理 key | POST | `/api/token/` | `CreateTokenRequest`(见下) | `data.key` / `success` |\n| key 列表 | GET | `/api/token/?p={p}&size={n}` | — | `data.items[]`(含 `id/key/name/status/remain_quota`) |\n| 更新 key | PUT | `/api/token/` | `{ ...token, id }` | `success` |\n| 删除 key | DELETE | `/api/token/{id}` | — | `success` |\n\n`CreateTokenRequest`:\n\n```json\n{\n \"name\": \"ccx-autopilot\",\n \"remain_quota\": 0,\n \"expired_time\": -1,\n \"unlimited_quota\": true,\n \"model_limits_enabled\": false,\n \"model_limits\": \"\",\n \"allow_ips\": \"\",\n \"group\": \"\"\n}\n```\n\n`quota` 是 new-api 内部额度单位(非美元),换算比例读 `GET /api/status` 的 `price` / `PaymentUSDRate`(无鉴权),或允许用户在订阅中手填换算率;MVP 直接以 quota 原值展示 + 站点换算率估算余额。\n\n**接入流程**(订阅中心「新增订阅 → 类型选 new-api」):\n\n```text\n1. 用户输入:站点 baseURL + 系统访问令牌(access_token) + userId\n (userId 可选自动获取:先用令牌调 /api/user/self 拿 data.id 回填)\n2. 校验:GET /api/user/self\n ├─ success=false / 401 → 令牌无效或 userId 不匹配,报错停止\n └─ 成功 → 记录 username、quota,创建 SubscriptionProfile(provider=new_api, originTier=second)\n3. 拉分组:GET /api/user/self/groups → 写入 GroupMultipliers({group: ratio})\n GET /api/user/models → 记录账号可用模型,供渠道 supportedModels 参考\n4. 建 key:POST /api/token/(按 ProvisionKeyName/Group/Models 模板)\n ├─ 已存在同名 ccx-autopilot key → 复用其 id,不重复创建(列表比对 name)\n └─ 新建成功 → 回填 ProvisionedTokenID,取 data.key 作为渠道 apiKey\n5. 自动建渠道:用 baseURL + 新建 key 创建一个上游渠道\n ├─ channelKind 由用户选择或按站点探测(messages/chat/responses/gemini)\n ├─ status=unknown,autoManaged=true,OriginType=relay\n ├─ 建立 SubscriptionUID → ChannelUID 链接(回填 LinkedChannelUID)\n └─ 触发 §8.4 的 Discovery + 能力测试标准流程\n6. 完成:订阅卡片显示余额、分组倍率、已绑定渠道、探测进度\n```\n\n关键点:new-api 集成**不额外造探测逻辑**,第 5 步之后完全复用现有 §8.4「添加并探测」链路;集成层只负责「令牌 → key + 分组 + 余额 + baseURL」的自动填充,把手填 baseURL/key 的动作自动化掉。\n\n**刷新与同步**(复用 `POST /api/subscriptions/{uid}/refresh`):\n\n| 触发 | 动作 |\n|------|------|\n| 手动刷新余额 | `GET /api/user/self` 更新 `Balance` + `BalanceUpdatedAt` |\n| 定时刷新(可选,opt-in) | 复用 Phase 4 Item 6 `SubscriptionAutoRefresh`;new-api 作为 `relay` provider adapter 纳入,默认关闭 |\n| 分组倍率变更 | 刷新时对比 `/api/user/self/groups`,`GroupMultipliers` 变化 → 更新并提示「套餐倍率已变」 |\n| key 失效检测 | 渠道侧探测到该 key 全 401 → 订阅卡片提示「key 可能已删除/禁用」,提供「重新建 key」一键动作(重跑第 4-5 步,复用旧渠道,只换 apiKey) |\n| 可用模型变更 | `/api/user/models` 结果哈希变化 → 触发对应渠道重新 Discovery |\n\n**安全与边界**:\n- `access_token` 权限等同 new-api 账户,须加密存储、脱敏展示、禁止写入日志;刷新失败连续 N 次自动停用该订阅的自动刷新,只保留手动。\n- 自动建 key 默认 `unlimited_quota=true` 但可由用户改为限额模板;建 key 是有副作用的写操作,须在接入前明确告知用户「将在 new-api 侧创建一个名为 ccx-autopilot 的令牌」。\n- new-api 是 `OriginTier=second`,**不得**用作隐私敏感的 routing advisor / classifier(遵守 §3.2.1 约束)。\n- fork 兼容:请求同时带 `New-API-User` 与 `User-id` 头;响应信封字段名(`success/data/message`)在部分 fork 可能不同,adapter 需容错解析,失败时回退手动录入。\n- 一个 new-api 订阅默认只自动建/绑定一个渠道;用户如需该站多协议渠道,可在同订阅下手动增建,共用同一 key。\n\n### 8.5.2 订阅 Provider Adapter 框架(多来源余额与接入)\n\n§8.5.1 的 new-api 集成不是特例,而是「订阅 Provider Adapter」框架的一个实现。Phase 4 Item 6 已落地 openai/anthropic/google 的余额刷新 adapter,本框架把它抽象成统一接口,让 new-api 及后续来源(GitHub Copilot、Codex、Nvidia、MiniMax、DeepSeek 等)按相同契约接入,避免每加一个来源就散落一套逻辑(DRY)。\n\n**统一接口**:\n\n```go\n// backend-go/internal/autopilot/subscription/adapter.go\ntype ProviderAdapter interface {\n Kind() string // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | \"copilot\" | \"deepseek\" | ...\n\n // 校验凭据,返回账号身份(用户名/账户标识);失败即凭据无效\n Verify(ctx context.Context, cred Credential) (AccountInfo, error)\n // 查余额/额度;不支持返回 ErrUnsupported,由 UI 降级为手动录入\n FetchBalance(ctx context.Context, cred Credential) (Balance, error)\n // 拉分组倍率;仅 new-api 类中转有,官方直连返回 ErrUnsupported\n FetchGroups(ctx context.Context, cred Credential) (map[string]GroupRatio, error)\n // 自动创建代理 key;官方直连通常不支持(key 即用户 API key),返回 ErrUnsupported\n ProvisionKey(ctx context.Context, cred Credential, tmpl KeyTemplate) (ProvisionedKey, error)\n // 可用模型列表;用于渠道 supportedModels 参考\n FetchModels(ctx context.Context, cred Credential) ([]string, error)\n}\n```\n\n每个能力独立、可选:adapter 只实现自己支持的部分,不支持的统一返回 `ErrUnsupported`(接口隔离原则),UI 据此隐藏/降级对应入口。所有 adapter 共用 `SubscriptionAutoRefresh` 的 opt-in 开关与 `Validate()` 兜底,与 Phase 4 现有防御模式一致。\n\n**能力矩阵**(✅ 支持 / ⚠️ 部分或需确认 / ❌ 不支持,走手动):\n\n| 来源 | Kind | 验证 | 查余额 | 拉分组 | 自动建 key | OriginTier |\n|------|------|:----:|:------:|:------:|:----------:|------------|\n| new-api family | `new_api` | ✅ | ✅ quota | ✅ | ✅ | second |\n| OpenAI 官方 | `openai` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Anthropic 官方 | `anthropic` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Google 官方 | `google` | ✅ | ⚠️ | ❌ | ❌ | first |\n| GitHub Copilot | `copilot` | ⚠️ token | ❌ 无额度概念 | ❌ | ❌ | first |\n| Codex plan | `codex` | ⚠️ | ⚠️ 用量窗口 | ❌ | ❌ | first |\n| Nvidia NIM | `nvidia` | ✅ | ⚠️ credits | ❌ | ❌ | first |\n| MiniMax | `minimax` | ✅ | ⚠️ | ❌ | ❌ | first |\n| DeepSeek | `deepseek` | ✅ | ✅ balance API | ❌ | ❌ | first |\n\n说明:官方直连来源普遍**不支持自动建 key**(用户的 API key 本身就是凭据,直接作为渠道 apiKey 录入即可)、也**没有分组倍率**(价格按官方定价表)。它们的 adapter 价值集中在「验证 + 查余额/用量窗口」,把 §3.2.4 UsageWindow 的官方用量 API 来源坐实。DeepSeek 有独立余额查询端点,可完整支持余额。Copilot 属订阅制、无 token 余额概念,只做验证 + 用量提示。\n\n**接入分层**(YAGNI:不强求一次做全,按价值排期):\n\n```text\nP0(本轮):new_api(完整:验证+余额+分组+建key+自动建渠道)\n —— 覆盖用户最主要的中转站场景\nP1:deepseek(验证+余额)、官方三家余额已在 Phase 4 完成 → 补 UsageWindow 对齐\nP2:nvidia / minimax(验证+credits/余额)\nP3:copilot / codex(验证+用量窗口提示,无余额)\n```\n\n每个来源的具体端点在实现时于 `adapter.go` 各 provider 文件内落地,本设计只固定接口契约与能力矩阵,避免文档与上游 API 变动强耦合。新增来源 = 实现 `ProviderAdapter` + 在能力矩阵登记,不改框架。\n\n**能力缺失与降级**:\n- adapter 任一能力返回 `ErrUnsupported` → 订阅中心对应字段回退为手动录入(余额手填、倍率手填),不报错、不阻塞接入。\n- adapter 验证失败/超时 → 订阅可仍以「手动模式」创建,仅失去自动刷新与自动建 key,其余流程(手填 baseURL+key 建渠道)不受影响。\n- 官方来源无「自动建 key」时,接入流程退化为 §8.4 标准快速添加:用户填 API key,adapter 只补验证与余额。\n- 所有自动刷新遵守 `SubscriptionAutoRefresh` 默认关闭;连续失败自动停用自动刷新并在驾驶舱「人工待办」提示。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3,\n \"providerTimePricing\": {\n \"deepseek\": {\n \"effectiveFrom\": \"2026-07-20T00:00:00+08:00\",\n \"timeZone\": \"Asia/Shanghai\",\n \"peakMultiplier\": 2,\n \"peakWindows\": [\n { \"start\": \"09:00\", \"end\": \"12:00\" },\n { \"start\": \"14:00\", \"end\": \"18:00\" }\n ]\n }\n }\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n`providerTimePricing.effectiveFrom` 使用 RFC3339;修改该值即可调整激活时间,设为空字符串则停用对应规则。\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**Phase 3A 状态(2026-07-09)**:低风险、不改真实路由语义的子集已完成并接入真实链路——`Manager.collectAll()` 新旧画像对比产出变更事件(`DetectProfileChanges`)、`ProfileChangelogStore`(环形内存 + SQLite,30 天机会性清理)、`EventHub` 内存 pub/sub、`GET /api/health-center/changelog`(历史)+ `GET /api/health-center/events`(WebSocket 实时推送)、`AutoDiscoveryRunner` 发布 `discovery_completed`/`auto_mapping_applied`、前端 `ProfileChangelogTimeline.vue` 接入健康中心页并支持断线自动重连。均为只读展示,未修改 `SmartRouter`/`EndpointAttemptPolicy` 调度逻辑。落地时顺带发现并修复一个真实缺口:浏览器原生 WebSocket 无法设自定义 header,为此给 `middleware.getAPIKey` 增加了 `Sec-WebSocket-Protocol` 鉴权回退(业界标准做法,仅新增一个 key 来源,不降低现有校验)。\n\n**Phase 3B(已全部完成,2026-07-09)**:模型自动映射、自动恢复探测、晋升/降级机制会直接改变真实调度决策,风险明显高于 3A,四个子项已分批全部完成。§12.2 P1.5 的 SQLite schema version / 版本化 migration / `OriginType`&`OriginTier` 旧配置 backfill / `killSwitch`+按 task class/channel disable **已于 2026-07-09 补强完成**(详见 §12.2 P1.5)。**Phase 3B-1(自动恢复探测补全)已于 2026-07-09 完成**:修复了 L2 探测子系统里此前从未打通的两个真实 bug——① `collectAll()` 每轮把新构造的画像整行 Upsert,会把 `ProbeWorker` 刚写入的 `LastProbeAt`/`ProbeSuccess`/`ConsecutiveProbeSuccess` 等字段清零,导致 6 小时探测冷却期形同虚设(修复:`carryForwardProbeFields` 在 Upsert 前搬运 Probe* 字段,不影响 L1 诊断字段的现有覆盖逻辑);② `extractRawAPIKey` 是返回 `KeyMask` 掩码值的占位 stub,探测请求实际带的是假 key(修复:新增 `APIKeyResolver` 回调 + `Manager.ResolveAPIKey`,通过遍历配置里对应渠道的 `APIKeys` 逐个计算 `KeyHashFromAPIKey` 比对来还原明文;resolver 未注入或未命中时 fail-open 跳过探测且不消耗预算)。同时补齐了 `degraded/limited → healthy` 的连续成功恢复路径(新增 `KeyEndpointProfile.ConsecutiveProbeSuccess` 计数器 + `HealthCheckConfig.ProbeRecoveryThreshold`,默认连续 2 次探测成功才恢复,避免单次探测噪声导致状态抖动;探测失败计数器清零)。**Phase 3B-3(StabilityTier 晋降级滞后)已于 2026-07-09 完成**(提交 664d3fff):在原本每轮从瞬时 1h 窗口重算的无记忆 `StabilityTier` 上加一层连续窗口滞后——新增 `KeyEndpointProfile.EffectiveStabilityTier`/`StabilityPendingTier`/`StabilityPendingStreak` 三字段 + 纯函数 `applyStabilityHysteresis`(对称阈值:连续 N 轮同值才切换生效档,`HealthCheckConfig.StabilityHysteresisWindows` 默认 2;首次画像直接采用,不等待阈值,避免新渠道卡在零值),`AggregateChannelProfile` 改读 `EffectiveStabilityTier`(空则回退 `StabilityTier`)。晋升/降级机制的数据模型仅限于 StabilityTier,不改变 QualityTier/SpeedTier/CostTier 语义。**Phase 3B-2(模型自动映射闭环)已于 2026-07-09 完成**(提交 0f02e988,随后修复一处 critical 集成 bug,提交 856372c4):新建 `ModelProfileStore`(SQLite,`model_profiles` 表)+ 扩展 `AutoDiscoveryRunner` 为每个发现模型写入 `ModelProfile`(仅 `AutoManaged` 渠道);新建 `ModelResolver` 实现 §5.4 五步逻辑(手动 `RedirectModel` 短路 → `GetModelProfiles` → `filterByCapabilityFloor` → `rankBySimilarity`);`scheduler` 包新增 `ModelSupportResolverFunc` hook(fail-open,未注入或返回不支持时严格回退到原 `ExplainModelSupport`);`endpoint_policy.go` 的 `resolveMappedModel`(此前是永不触发的死代码——`profile.ModelMapping` 从未被写入)现调用 `ModelResolver`;`handlers/common` 在 `RedirectModel` 之后应用 `MappedModel` 覆盖请求体,并按 `EchoMappedModel` 配置回显 `X-CCX-Mapped-Model`/`X-CCX-Original-Model`/`X-CCX-Mapping-Source` 响应头。三条件安全门控:`ModelMapping.AutoResolve==true`(**默认由死配置的 true 改为 false**)AND `RoutingMode ∈ {assist, auto}` AND 无 `KillSwitch`,三者任一不满足则所有新增代码路径行为与今天字节级一致;且仅 `AutoManaged` 渠道触发,手动配置渠道完全不受影响。对抗性复核发现并修复一个真实 critical bug:`scoreEndpointForKey` 用空 `channelUID` 计算 `EndpointUID` 并写入 `modelByUID`,而 `handlers` 层用真实 `channelUID` 查 `modelByUID`,两者哈希永不相等导致 `MappedModel` 在 key 级调度路径上完全查不到(提交 856372c4 修复:命中画像后改用 `profile.EndpointUID`,新增回归测试锁定该不变量)。至此 Phase 3B 四个子项(自动恢复探测、StabilityTier 滞后、模型自动映射、active filter 前置判定)全部完成;SLO regression 自动回滚仍是独立子系统,留待后续单独立项。\n\n**范围**:\n- [x] 运行时指标驱动画像实时更新(Phase 3A:`collectAll()` 每轮 diff + changelog + WebSocket 推送)\n- [x] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)—— Phase 3B-2(2026-07-09 完成,提交 0f02e988 + 856372c4)\n- [x] active model filter / context filter 支持自动映射前置判定 —— Phase 3B-2:`scheduler.ModelSupportResolverFunc` fail-open 接入 `select.go`\n- [x] 自动恢复探测(limited/dead → healthy)—— Phase 3B-1(2026-07-09 完成):`APIKeyResolver` + `carryForwardProbeFields` + `ConsecutiveProbeSuccess`/`ProbeRecoveryThreshold`\n- [x] 晋升/降级机制(连续成功→升级,连续失败→降级)—— Phase 3B-3(2026-07-09 完成,提交 664d3fff):`StabilityTier` 连续窗口滞后(`EffectiveStabilityTier`),暂不含 QualityTier/SpeedTier/CostTier\n- [x] WebSocket 推送画像变更事件(Phase 3A:`GET /api/health-center/events`)\n- [x] 前端画像变更历史/时间线(Phase 3A:`ProfileChangelogTimeline.vue`)\n\n**预估工期**:2-3 周(3A、3B-1、3B-2、3B-3 均已完成;advisor 与全局 auto 的 SLO 回滚均已补齐)\n\n> 状态校正(2026-07-17):上方 Phase 3B 历史说明中的“SLO regression 自动回滚待单独立项”已完成;advisor 回滚与全局 auto 上线闸门/自动降级是两个独立保护层。\n\n### Phase 4:高级特性\n\n**Phase 4 状态(2026-07-09,全部完成)**:8 项全部实现并接入真实链路,均遵循\"默认关闭 + 显式 opt-in\"安全守则。\n\n**范围**:\n- [x] 多维度标签系统扩展(用户自定义标签)——Item 1\n- [x] 成本报表:按用户/模型/渠道/key 统计真实有效成本——Item 2(新增 `proxyKeyMask` 身份贯穿链路:`middleware.GetProxyKeyMask` → `ChannelLog`/`RequestRecord` → SQLite `proxy_key_mask` 列,供 Item 4 复用)\n- [x] Trusted advisor SLO regression 自动回滚——Item 3(默认关闭;`SLORollbackConfig`,连续 degrading 窗口数默认 3,不做自动恢复以避免震荡)\n- [x] 渠道推荐:根据使用模式推荐新渠道——Item 4\n- [x] 批量渠道管理(导入/导出/模板)——Item 5\n- [x] 订阅中心 provider adapter:可选自动刷新余额/套餐状态——Item 6(默认关闭;仅 openai/anthropic/google 且显式填写 BillingAPIKey 的订阅生效,中转/公益渠道继续手动维护)\n- [x] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板——Item 7\n- [x] A/B 测试:低比例统计抽样双发——Item 8(默认关闭,默认 1% 抽样 + 每小时 60 次硬预算;主请求路径不受影响,影子请求在主响应返回后异步发起)\n\n**关键实现说明**:\n- Item 3/6/8 均在 `AutopilotRoutingConfig` 新增独立配置块(`SLORollback`/`SubscriptionAutoRefresh`/`ABTest`),`Validate()` 统一做负值/越界兜底,保持与既有字段一致的防御模式。\n- Item 4 直接复用 Item 2 的 `proxyKeyMask` 身份标记,未重新发明身份识别机制。\n- **全局 auto 安全闸门(2026-07-17)**:请求终态按 15 分钟窗口无偏聚合,详细 trace 继续抽样;管理 API 只在 shadow/assist 已积累至少 500 个有效请求、24 小时连续观测且成功率/fallback/fail-open/p95 均达标时允许切换 `auto`。后台连续检测三个已完成 auto 窗口,相对最近七天安全模式基线持续恶化时,持久化降级到 `assist` 并记录 `autopilot_auto_safety_events`,不自动恢复。\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- ~~SQLite schema version 和幂等 migration。~~ **已完成**:`ensureSchemaVersion` 基于 `PRAGMA user_version`,当前 7 张表定版为 v1 基线;库版本高于代码版本时返回 error(fail-closed,不做任何写操作),交给下游 fail-open 分支接管。\n- ~~旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。~~ **已完成**:`ensureOriginBackfill` 只补 `\"unknown\"`,不做任何基于 URL/名称的猜测推断;已接线到 `Profiler.DeriveEndpointProfile`,`BreakTieByOriginTier` 现在能吃到真实数据(此前一直是 fallback 的 `unknown`)。\n- ~~profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。~~ **已确认满足并测试锁定**:`main.go` 现有结构本就是 fail-open(`NewProfileStore`/`NewManager` 出错只打日志、`autopilotManager` 保持 `nil`,下游全部有 `nil` 守卫),本次新增 `schema_migration_test.go` 用测试锁定这条不变量。\n- ~~全局 `killSwitch`、按 task class disable、按 channel disable。~~ **已完成**:新增 `AutopilotRoutingConfig.DisabledTaskClasses`/`DisabledChannelUIDs`;`CandidateFilterFor`/`executeFilter`/`BuildPlan` 三处一致接入,`smart_router_invariant_test.go` 覆盖\"task class 禁用后回退默认调度\"\"channel 禁用后不出现在候选里\"\"dry-run 与真实路径行为一致\"三条不变量。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。**部分完成**:`ReleaseController.EvaluateAndApplyRegression` 已实现三窗口回归检测和 `SafetyOverride` 降级机制;`RoutingModeActive` 已加入配置枚举和 `IsAutopilotActive`。\n\n#### P1.6 Trace v2 契约与灰度发布\n\n> 对应实施计划:`docs/superpowers/plans/2026-07-23-autopilot-trace-rollout.md`\n\n**Trace v2 核心变更:**\n\n- `RoutingDecisionTrace` 增加 `SchemaVersion`、`TraceRevision`、`RequestCorrelationId`、`ReleaseID`、`PolicyFingerprint`、`TargetMode`/`EffectiveMode`/`Cohort`、`SchedulerDecision`、`EndpointAttempts` 字段。\n- 新增 `TraceDetailV2`、`TraceSummary`、`SchedulerDecisionSummary`、`EndpointAttemptSummary` DTO;使用显式 DTO 落盘而非直接 JSON 化运行时结构。\n- `ComparisonStatus` 三态枚举(`matched`/`mismatched`/`uncompared`)替代裸 `bool`。\n- `SanitizeForPersistence` 与 `SanitizeForResponse` 双边界脱敏;`GenerateTraceUIDv2` 使用 `crypto/rand` 碰撞安全生成。\n- `RoutingReleaseSnapshot` 不可变发布快照,入口冻结后续只读。\n\n**SQLite v6 迁移:**\n\n- trace 表新增列:`schema_version`、`trace_revision`、`request_correlation_id`、`release_id`、`policy_fingerprint`、`persistence_class`、`details_json`。\n- 窗口表重建主键:增加 `release_id`/`policy_fingerprint`/`cohort` 维度,新增 `compared_count`/`matched_count`/`mismatch_count`/`uncompared_count` 全量比较计数。\n- safety event 表增加 `release_id`/`policy_fingerprint` 字段。\n\n**Autopilot 单一运行态(2026-07-28):**\n\n顶层 `off → shadow → assist → auto → active` 灰度状态机已经废止。系统启动后固定执行 Autopilot 自动路由:应用硬约束过滤并按评分重排,候选全部被过滤时 fail-open。旧配置中的 `mode` 值会在加载时统一归一化为 `auto`,不再提供模式切换 API 或管理界面。\n\n`AUTOPILOT_KILL_SWITCH` 与配置中的 `killSwitch` 是唯一的全局急停入口;启用后不注入 SmartRouter 和 EndpointAttemptPolicy,恢复调度器默认行为。管理端 dry-run 仍作为独立诊断能力保留,不属于运行模式。\n\n发布批次、策略指纹和 cohort 字段继续保留在 Trace 契约中,用于兼容历史数据及诊断聚合;`ReleaseController`、readiness 样本门槛、自动降级/恢复和灰度模式切换代码均已删除。\n\n**只读 Trace API:**\n\n| 接口 | 返回 | 约束 |\n|---|---|---|\n| `GET /api/autopilot/traces` | `TraceSummary[]` | 游标分页、release/cohort/mode 过滤、partial 标记 |\n| `GET /api/autopilot/traces/:traceUid` | `TraceDetailV2` | 404=未找到/已过期/未采样;503=存储不可用 |\n| `GET /api/autopilot/traces/stats` | `TraceStatsResponse` | 三态比较统计、Mode/TaskClass 分布 |\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | advisor/local-model 保留独立 shadow 门槛;全局 Kill Switch 可立即停止 Autopilot |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "d971de1e20ffe917724faa91e964fed6adeb6a49": {"t": 1785759333.2941525, "n": 167702, "h": "d1ded718c6ec19e4", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.2.3 同订阅多 Key:能力共享与配额负载均衡\n\n**问题**:官方订阅 plan(Claude Max、Codex plan 等)允许用户持有多个 key,或购买多份相同套餐。若每个 endpoint 独立探测与配置,探测预算被浪费、配置重复;请求量大时还需要在同 plan 的多个 key 之间按官方 RPM 限速自动负载均衡,而不是打满第一个 key 才溢出到下一个。\n\n**能力检测去重(同一套餐只需一套能力检测和配置)**:\n\n```text\nSubscriptionUID 相同的 endpoint 共享一份「订阅级能力画像」:\n 模型列表 / vision / tool / reasoning 支持 / 协议兼容开关 → 只探测一次,\n 存在 SubscriptionProfile.SharedCapability,endpoint 画像以引用方式继承。\n\n仍然 per-endpoint 维护的:健康状态、延迟、熔断、用量窗口\n (key 可能被单独封禁/限速,能力相同不代表运行态相同)\n\n继承失效条件:某 key 实测模型列表哈希与订阅级不一致(GroupChangeDetector 信号)\n → 该 key 脱离继承、独立探测,并在 UI 标记「与套餐能力不一致」警告。\n```\n\n**配额负载均衡(Phase 1 shadow 展示,Phase 2 生效)**:\n\n```text\n每 key 维护配额余量视图:\n remainingRPM = officialRPM − 当前窗口已用\n officialRPM 来源优先级:内置 manifest > 用户配置 > RateLimitDiscoverer 建议\n\n同订阅多 key 时按「剩余余量加权」分配请求(余量大者优先、\n窗口最早重置者兜底),替代顺序打满:\n 仅当渠道 autoManaged 且同一 SubscriptionUID 下 key 数 > 1 时启用;\n 否则保持现有 sequential/random key 顺序策略不变。\n\n某 key 的窗口配额耗尽 → 临时降权并切到同订阅其他 key,\n不熔断整个渠道(这是配额耗尽,不是故障)。\n```\n\n### 3.2.4 订阅用量窗口 (Usage Windows)\n\n部分官方 plan 提供 5h / 日 / 周 / 月等窗口的用量查询与限制(如 Claude 订阅的 5h 滚动窗口 + 周配额)。在 key 的位置展示这些窗口,帮助用户掌握使用情况:\n\n```go\ntype UsageWindow struct {\n Window string `json:\"window\"` // \"5h\" | \"day\" | \"week\" | \"month\"\n Used float64 `json:\"used\"` // 已用量\n Limit float64 `json:\"limit\"` // 上限,0 = 未知\n Unit string `json:\"unit\"` // requests | tokens | credits | percent\n ResetsAt time.Time `json:\"resetsAt\"` // 窗口重置时间\n Source string `json:\"source\"` // official_api | response_header | local_metering\n FetchedAt time.Time `json:\"fetchedAt\"`\n}\n// KeyEndpointProfile 与 SubscriptionProfile 均增加 UsageWindows []UsageWindow\n```\n\n数据来源优先级:\n\n```text\n1. 官方用量 API:有 provider adapter 的 plan 主动拉取(§Phase 4 订阅 adapter 的可提前子集)\n2. 响应头:anthropic-ratelimit-* / x-ratelimit-* 等(复用 RateLimitDiscoverer 的信号 tap)\n3. 本地计量兜底:CCX 按 key 累计请求数/token(误差来自窗口起点对齐,标注 Source=local_metering)\n```\n\nUI 要求:key 行内显示主窗口(5h)用量进度条,悬浮展开全部窗口;用量 >80% 黄色预警、超限红色并显示预计重置时间。调度联动(Phase 2):窗口余量进入配额负载均衡权重,窗口耗尽的 key 临时降权而非熔断。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n#### 4.2.1 内置模型清单 (Builtin Models Manifest)\n\n**问题**:部分官方订阅 plan 的入口没有正确的 models 接口——要么不存在(404)、要么需要额外权限、要么返回与订阅实际可用模型不符的列表(典型:Claude 订阅 OAuth 入口、Codex plan 入口、部分官方企业网关)。对这些 baseURL 做模型发现会失败或产出错误画像。\n\n**方案**:CCX 内置「已知官方 baseURL → 模型清单」映射表,命中时直接返回内置内容,完全跳过上游 models 接口:\n\n```go\n// backend-go/internal/config/builtin_models_manifest.go(生成式,随模型注册表更新)\ntype BuiltinModelsManifest struct {\n BaseURLPattern string `json:\"baseUrlPattern\"` // 精确 host 或前缀匹配,如 \"api.anthropic.com\"\n ServiceType string `json:\"serviceType\"` // messages/responses/…\n PlanHint string `json:\"planHint,omitempty\"` // 订阅类型提示(如 \"claude_subscription\")\n ModelIDs []string `json:\"modelIds\"` // 该入口实际可用的模型清单\n DisableProbe bool `json:\"disableProbe\"` // true 时 Discovery 跳过 GET /v1/models\n}\n```\n\n**两个生效位置**:\n\n```text\n1. Discovery(§4.2 流程第 2 步之前):\n baseURL 命中 manifest 且 DisableProbe=true\n → 跳过 GET /v1/models,直接用 ModelIDs 生成 ModelProfile[]\n → ProfileSource = \"builtin_manifest\",不再因 models 接口失败而误判 misconfigured\n\n2. /v1/models 代理响应(现有 Models API):\n 客户端向 CCX 请求 /v1/models 时,聚合结果中该渠道的贡献\n 直接取内置清单,不向上游发起 models 请求\n (渠道显式配置了 supportedModels 时仍以用户配置优先)\n```\n\n**维护与覆盖顺序**:\n\n```text\n渠道显式 supportedModels(用户手动) > 内置 manifest > 上游 models 接口实探 > 内置候选模型回退\n```\n\nmanifest 与模型注册表同源生成(`generated_model_registry.go` 同级),新模型发布时随注册表一起更新;HealthAnalyzer 对命中 manifest 的 endpoint 不将「models 接口 404/403」计入 misconfigured 信号。\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n MappedEffort EffortLevel // 自动映射的 effort 级别(空=未决定)\n MappedEffortDecided bool // effort 是否由 Autopilot 决定\n EndpointUID string // 稳定 endpoint ID\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n\n // ResolvedModelByEndpointUID(旧 API):仅返回模型名,向后兼容。\n // ResolvedTargetByEndpointUID(新 API):返回 model + effort 原子决策。\n // 两者从同一份 targetByUID 派生,避免两个真值源。\n ResolvedModelByEndpointUID func(endpointUID string) string\n ResolvedTargetByEndpointUID func(endpointUID string) *ResolvedRouteTarget\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20;assist 下 dead/misconfigured 额外 -100 并保留在队尾\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法细分时进入 `general`;有规范 Knowledge 基准则使用其软评分,缺少直接证据时仍取 0.5 中性值。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n**手动 L3 第一版状态(2026-07-15)**:已实现 `pq-v1-20260715` 固定 JSON canary,覆盖\nMessages、Chat Completions、Responses 与 Gemini 上游协议。调用方只能指定 `endpointUid`、\n`modelId` 和 1~3 次重复数,不能注入 prompt。评分仍按 40% 完整性、30% 语义正确性、\n15% 严格 JSON 格式、15% 延迟执行;第一版使用非流式完整响应延迟作为首 token 延迟代理,\n后续引入流式采样时再替换该维度。单次有效样本 confidence=0.6,最多三次提升到 0.8;\n失败样本按 0 分计入均值,全部失败不写画像。\n\n安全边界:仅管理 API 手动触发,不启动自动 worker;当前进程按 UTC 日设置 12 次硬预算,\n请求前原子预留,单次 90 秒超时、响应体上限 64 KiB。同一 endpoint×model 不并发执行;\n单次请求最多允许 2048 个输出 token,以容纳推理模型的隐藏思考并限制最坏额度消耗。\n对声明可控推理的模型固定采用最低可用 effort;Claude 兼容端点若只暴露\n`reasoning_content` 而未声明 thinking 模式,则与 capability-test 一致显式关闭 thinking。\n原始模型输出和明文 Key 既不落盘也不返回,只保存分数、canary 版本、置信度和脱敏判定。\n`user_feedback` 仍高于 `probe`,自动发现重建 ModelProfile 时必须保留既有质量证据。\n跨进程共享预算与 LLM-as-judge/语义向量评分仍属于后续阶段。\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设 + 自定义\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n| `custom` | 用户自定义 | 用户自定义 | 高级用户微调,范围 0.0 ~ 3.0 |\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法细分的通用任务;缺少基准证据时中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general;有规范 Knowledge 基准时使用其软评分,缺少直接证据时取 0.5 中性。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? canonicalBenchmark[model][domain] * providerQualityFactor\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 规范模型基准 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n#### 5.7.4 规范模型能力基准\n\n`shared/model-registry/ccx_model_registry.json` 的 `benchmarkProfiles` 独立保存规范模型能力上界,不与 `upstreamCapabilities` 混合。后者仍是 context、vision、tool calls 等硬能力的事实源;benchmark 只参与 Autopilot 软评分和 trace 解释,绝不改变 `supportedModels` 或 CapabilityFloor。\n\n```text\nEffectiveCapability[domain]\n = CanonicalCeiling[model, domain]\n × ProviderQualityFactor[endpoint]\n\nProviderQualityFactor = 1 - confidence × (1 - providerQualityScore)\n无渠道质量证据或 confidence<0.5 时 factor=1;可信证据只能从规范模型上界向下折算。\n```\n\n基准数据必须保留领域向量、来源、核验日期、lane、共享结果数和可比类别覆盖率,不能只保存站点 Overall。当前 BenchLM 接入为 `provisional`:Claude Opus 4.8、GPT-5.6 Terra、GPT-5.6 Sol 各有 30 个共享结果,8 类中 5 类可比,核验日期为 2026-07-13。\n\n领域映射:Knowledge → `general`,Math → `reasoning`,Coding → `coding` / `code_review`,Agentic → `agentic`,Multimodal → `aesthetics_ui`(仅弱代理,解释置信度减半)。Writing / Translation 没有直接类别证据时继续回退家族种子或 0.5。BenchLM 的 cohort 归一化、缺失项插补和外部校准决定了 Overall 不是绝对能力值,因此路由只消费可映射的原始类别分。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n#### 5.8.4 联合决策的现状实现(model + effort 原子化)\n\n> 状态说明(2026-07-26):本节描述的联合决策链路已实现并接入真实请求路径(关联计划 [2026-07-26-autopilot-model-effort-joint-routing.md](../superpowers/plans/2026-07-26-autopilot-model-effort-joint-routing.md) 的 Task 1~7)。**渠道级手工 `modelMapping` / `reasoningMapping` 表目前仍然存在且仍然生效**,退役是该计划的 Task 8,尚未执行;手工表当前的优先级关系见下文。\n\n**决策对象。** 自动路由的返回值不再是裸模型名,而是 `autopilot.ResolvedRouteTarget`(`backend-go/internal/autopilot/route_target.go`):\n\n```go\ntype ResolvedRouteTarget struct {\n Model string // 目标模型 ID\n Effort EffortLevel // 目标思考档位;空串表示不改写\n EffortDecided bool // true 表示 effort 由 Autopilot 决定;false 为 passthrough\n Reason string // 决策原因,用于 Trace 与日志\n}\n```\n\n`EffortDecided=false` 且 `Effort` 为空是合法的 fail-open 结果:模型可能被改写,但思考参数保持原样。\n\n**决策发生的位置。** `autopilot/endpoint_policy.go` 的 `resolveMappedModel()` 是统一入口,内部分两路:`resolveManualMapping()` 处理渠道级显式 `ModelMapping`(仍是当前优先级更高的一路,返回的 `ResolvedRouteTarget.EffortDecided` 恒为 `false`,即手工映射只改模型不改档位);`resolveAutoModel()` 调用 `ModelResolver` 做自动模型解析,并在 `model_resolver.go` 的 `rankEligibleModels()` 中按 `SupportedEffortLevels` × `PerTaskClass` 展开 `(model, effort)` 候选、应用 `EffortQualityBonus` 与 anti-effort-inflation 反膨胀 tiebreak(同质量档优先选低 effort)、并按 `EffortFloor` 过滤掉低于任务下界的候选。产出的 `ResolvedRouteTarget` 经 `buildResolvedTargetLookup()` 挂到 `EndpointAttemptPolicy.ResolvedTargetByEndpointUID`,与旧的 `ResolvedModelByEndpointUID`(仅模型,向后兼容)并列。\n\n**决策如何到达上游请求。** `handlers/common/upstream_failover.go` 的 `TryUpstreamWithAllKeys` 在为每个 endpoint 尝试构建请求前,通过 `ResolvedTargetByEndpointUID` 取得 `*ResolvedRouteTarget`,交给 `atomicModelEffortRewrite()` 做一次原子改写:先用 `sjson` 改写 `model` 字段;若 `EffortDecided=true` 且 `Effort` 非空,再检查目标模型的 `ThinkingMode`——`adaptive_only` 的模型跳过思考参数注入(模型自身决定思考深度,守卫逻辑对齐 `providers/claude.go` 的 `applyClaudeReasoningEffort`),否则调用 `config.ApplyReasoningParamStyle()` 写入。模型改写与档位改写共用一次函数调用,任一步 `sjson`/`json` 处理失败即整体放弃、保持原始 body,不会出现\"模型改了、档位没改\"的中间态。\n\n**各协议的注入形态。** `config.ApplyReasoningParamStyle(req, style, effort)`(`config/config_utils.go`)按 `upstream.ReasoningParamStyle` 三态分支:\n\n```text\nstyle=\"thinking\" → Claude 派系:写 thinking.type=enabled + thinking.effort=;\n effort 为 off/none 时写 thinking.type=disabled;同时清理互斥字段\n reasoning / reasoning_effort / thinking.budget_tokens\nstyle=\"reasoning_effort\" → OpenAI Chat 派系:写顶层 reasoning_effort 字段,清理 reasoning\nstyle=\"\" / 默认 → Responses 派系:写 reasoning.effort 对象形态\n```\n\n**Gemini 派系是尚未接通的缺口。** 请求侧提取(`extractGeminiEffortExplicit()`,`handlers/common/reasoning_log.go`)已经能读取 `generationConfig.thinkingConfig.thinkingLevel` 并识别显式 `none`(`thinkingBudget=0`),这条读路径用于日志与 `RequestProfile.ClientEffort` 提取。但写路径——把联合决策的 `target.Effort` 写回 Gemini 请求体的 `thinkingConfig.thinkingLevel`——目前代码中不存在:`atomicModelEffortRewrite()` 唯一的写出口是 `ApplyReasoningParamStyle()`,它只覆盖 Claude/Chat/Responses 三种形态,没有 Gemini 分支;`handlers/gemini/handler.go` 的 `buildProviderRequest()` 也没有调用任何 effort 注入函数。也就是说,Gemini 渠道当前仍会经过 endpoint 级模型改写(`atomicModelEffortRewrite` 会改写 `model` 字段本身),但联合决策选中的思考档位不会落到 Gemini 请求体上,效果等同于 fail-open passthrough。此项在原计划里归属 Task 5 的\"Gemini 派系注入路径\",尚未实现。\n\n**可观测性字段。** 原子改写点会向 `gin.Context` 写入两个字段供 ChannelLog 使用:`effortDecisionSource`(`autopilot` 表示由联合决策产出,`passthrough` 表示未改写或手工映射路径)、`effortClampedByClient`(当客户端显式声明的档位低于 Autopilot 选择的档位时置 `true`,由 `isEffortClampedByClient()` 基于 `EffortLevelOrdinal` 比较得出)。\n\n**手工映射表的现状。** `resolveMappedModel()` 当前的分支顺序是\"显式 `ModelMapping` 优先,自动决策兜底\"——与原计划 Task 7 描述的\"反转优先级\"目标相反的方向仍是默认值;反转开关已作为可配置项落地(提交 `d15e776e`),但默认状态未切换,`ModelMapping` / `ReasoningMapping` 两张表本身尚未删除,`config.ResolveReasoningEffort()`(`config/config_utils.go`)与 provider 层(如 `providers/claude.go`)仍会读取 `ReasoningMapping` 独立生效。表的整体退役是 Task 8 的范围,未开始。\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**请求意图边界(ModelRoutingPolicy)**\n\n跨模型替代不是所有模型的默认能力,只对白名单客户端入口开放:\n\n```text\nmessages:\n fable / opus / sonnet / haiku 及对应 claude-* 完整模型名 → adaptive\nresponses:\n gpt-5.6* / gpt-5.5 / gpt-5.4* / codex-auto-review → adaptive\n其他协议或模型:\n exact-only,只能在探测到相同规范化模型 ID 的 endpoint 之间调度\n```\n\n`adaptive` 允许在满足 Capability Floor 后选择 GLM、MiMo 等其他模型族;`exact-only` 禁止跨模型替代。用户显式配置的 `modelMapping` 仍优先于该策略。调度预览和 endpoint 实际解析必须调用同一分类函数,避免诊断结果与真实请求分叉。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ModelProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选优\n // 上下文窗口只负责准入,不对更大的窗口扣分。\n // 排序优先级:\n // a. 模型质量档越高越优先\n // b. provider 已确认的同档模型能力顺序\n // c. 带置信度折算的供应商实测质量\n // d. 探测延迟\n // e. 同渠道/provider 的模型相对消耗倍率\n // 倍率相同或缺失时再比较公开模型成本\n // f. 同模型族与 model ID 仅作为确定性兜底\n best := rankEligibleModels(eligible, requestModel)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n同渠道套餐倍率与公开 USD/Token 价格是不同单位,禁止直接混算。模型解析器先比较 provider\n提供的相对消耗倍率;只有双方倍率相同,或双方均无倍率时,才回退到统一输入/输出规模下的\n公开价格。优云智算当前内置倍率如下(数字越小越省,`glm-5.2` 的 2 次为限时值):\n\n| 模型 | 每次调用套餐消耗 |\n|------|-----------------:|\n| `glm-5.2` | 2 |\n| `glm-5.1` | 6 |\n| `MiniMax-M2.7` | 2 |\n| `kimi-k2.6` | 5 |\n| `deepseek-ai/DeepSeek-V3.2` | 1 |\n| `deepseek-v4-flash` | 1 |\n\nprovider 可以额外声明同一质量档内已确认的模型能力顺序,但只有该档全部候选都命中该元数据\n时才比较,避免把未知模型误判为弱模型,也保证排序不依赖候选输入顺序。优云模板当前记录\n`glm-5.1 > kimi-k2.6`,因此前者即使消耗 6 次,也会在后者的 5 次成本证据之前胜出。\n`glm-5.2` 即使消耗 2 次,也仍会在质量档高于 DeepSeek 候选时优先;V3.2 与 V4 Flash\n同为 1 次时,再由公开价格及最终确定性规则区分。静态 `modelMapping` 仅保留为用户显式覆盖,\n不承担自动替代策略。\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - autoManaged 的空 supportedModels 不表示支持全部,必须由 endpoint ModelProfile 裁决\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n当 ModelResolver 已正常初始化且路由处于 `assist/auto + autoResolve=true` 时,autoManaged 渠道的画像拒绝是权威结果,调度器不得再 fail-open 到空 `supportedModels` 的兼容语义。Resolver 不可用、开关关闭或 `off/shadow` 时仍保留原有 fail-open,保证急停和降级路径不依赖画像库。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n### 6.9 渠道兼容性统一自学习\n\n渠道级兼容性配置不应要求用户先踩坑、再去翻设置面板找开关。这一节描述如何把这些开关从\n「静态布尔」转为「运行时学习到的能力事实」。\n\n**优先级合成**(`config.resolveCompatSwitch`)\n\n```text\n最终生效值 = 学习结论 ?? 历史种子(14 天内) ?? 静态默认\n```\n\n六个兼容性字段(`NormalizeNonstandardChatRoles`、`StripImageGenerationTool`、\n`CodexNativeToolPassthrough`、`StripEmptyTextBlocks`、`PassbackReasoningContent`、\n`PassbackThinkingBlocks`)已从 `UpstreamConfig`/`UpstreamUpdate` 整体删除:管理面板不提供编辑\n入口,渠道更新接口不接受手工写入,完全交由运行时自动决策。不再有「用户当前显式设置」这一档——\n历史手工值不代表可信的长期事实,可能当初就设错,也可能上游后来有了新情况已不再适用,因此不作为\n可以无限期压过学习的强制覆盖,只作为学习结论缺失时的一次性低置信度提示。\n\n学习结论通过 `UpstreamConfig.LearnedCompatTraits`(运行时字段,`json:\"-\"`,不落盘)传递:\nfailover 在发送前按 渠道-Key-模型 查询 `ChannelCompatCache`,把结论注入当次请求专用的\nupstream 副本,provider 构造请求时读取。\n\n**历史手工配置降级为种子**(`migrateManualCompatSwitchesToSeeds`):老用户配置文件里的六个字段\n已从结构体删除,只能在启动时从磁盘原始 JSON 里读到历史手工值。迁移把这些值降级为\n`UpstreamConfig.CompatSeeds`(`map[string]CompatSeedEntry`,带 `ExpiresAt`):\n\n- 种子带 **14 天有效期**(`CompatSeedTTL`),过期后自动失效、不再参与判断,回落学习结论或静态\n 默认重新判断。种子与学习记忆分开存储且 TTL 不同(种子 14 天 vs 学习记忆 24 小时),因为二者\n 语义不同:种子是\"曾经的一个信号\",学习记忆是\"渠道-Key-模型级的当前能力事实\"。\n- 迁移保留 `false` 值:用户「显式关闭」同样是他曾观察到的行为证据。\n- 迁移当下不改变可感知行为(学习缺失时按种子生效),但学到真实结论后立即让位,种子过期后也\n 自动退场。\n- 幂等:已有种子的渠道跳过。\n- 自动托管渠道在 `RuntimeUpstreamForAutoManagedProvider` 里连种子一起清空,其兼容性完全由\n 厂商原生默认值加运行时学习决定。已知厂商协议事实(如 GLM 的 OpenAI 兼容协议要求\n `passbackReasoningContent`)不走种子这条给\"可能出错的历史手工值\"用的临时通道,而是在对应\n accessor 里按 `ProviderID`/`ServiceType` 直接计算为静态默认(`shouldPassbackReasoningContentByDefault`\n 之类),因为这是不会过期的协议事实而非猜测。\n\n**记忆载体**:`config.ChannelCompatCache`,落盘 `.config/channel_compat.json`,\n键 `channelUID:keyHash:model`,TTL 24h(与 `DeprecatedParamCache`/`SystemHeaderFilterCache` 一致)。\n`Record` 仅在结论新增或翻转时返回 `true`,调用方据此决定是否重试,防止记忆生效后死循环。\n\n**三条学习路径**(按上游信号强度区分,不可一概而论)\n\n| 信号类型 | 兼容项 | 学习方式 |\n| --- | --- | --- |\n| 硬 400/422 + 可正则匹配 | developer role 降级、Codex 专属工具剥离、历史 thinking 回传、图片工具剥离 | 错误驱动:`CompatTraitFromError` 识别 → 记忆 → 同 Key 立即重试 |\n| 无报错,仅响应形态差异 | `passbackReasoningContent`、`stripEmptyTextBlocks` | 探针驱动:首次遇到该组合时 singleflight 后台探测一次 |\n| 厂商文档已公布约束 | Kimi 固定值参数、`tool_choice`、`thinking` | 前置规避:`ApplyKnownParamConstraints` 发送前直接改写,无需失败一次 |\n\n**分层职责**:`upstream_failover` 负责识别信号、写缓存、编排重试、把结论注入 `upstreamCopy`;\n各 provider 在构造上游请求时读取合成值并执行改写。这样 developer role 降级发生在\nResponses→Chat 转换**之后**的 `reqMap` 上,避开「failover 层拿到的是 Responses `input` 而非\nChat `messages`」这个陷阱。\n\n**防误判约束**(学错并持久化比不学更糟)\n\n- 双条件门控:正则命中之外,还要求请求侧确实携带对应结构(`BodyHasDeveloperRole` 等)\n- 单次重试上限:`Record` 返回 `false` 即不再重试\n- 不跨维度污染:结论不外溢到同渠道其他 Key/模型\n- 仅 400/422 参与学习;429/5xx/超时属容量问题,不是能力问题\n- fail-open:缓存损坏、探测失败一律退化为静态默认,不阻断代理请求\n\n**图片工具归因修正**:图片生成受限时,原实现直接拉黑 (Key,模型)。现改为先学习\n「需剥离图片工具」并重试修复请求,只有剥离后仍失败才回落拉黑——从「惩罚渠道」改为「先修复请求」。\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\nGET /api/health-center/provider-quality/budget → 查看本进程手动 L3 每日预算\nPOST /api/health-center/provider-quality/probe → 对指定 endpoint×model 执行固定质量 canary\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n#### 8.0.1 实现现状与演进方向(2026-07-09 核实)\n\n四界面在 **Web 前端(`frontend/src`)已大面积落地**,不是纯文档:\n\n| 界面 | Web 实现 | Desktop 实现 |\n|------|----------|--------------|\n| 健康中心 | ✅ `HealthCenterView.vue` + `HealthCenterStats`/`HealthChannelTable`/`HealthChannelDetail`/`ProfileChangelogTimeline`,路由 `/health` | ❌ 无 |\n| 订阅中心 | ✅ `SubscriptionsView.vue` + `SubscriptionPlanTable`,CRUD + 刷新余额,路由 `/subscriptions` | ⚠️ 独立旧实现 `subscriptions/SubscriptionTab.vue`(与 Web 两套代码) |\n| 渠道中心 | ✅ `ChannelOrchestration.vue`(六态 `ChannelHealthBadge`、来源 chips、`autoManaged` 图标齐备) | ❌ 未同步 |\n| 管理面板 | ⚠️ `AutopilotModePanel.vue` 仅暴露 mode/killSwitch/costPreference | ❌ 无 |\n| 驾驶舱 | ✅ `CockpitView.vue`(观测型总览:健康统计+余额汇总+渠道推荐) | ⚠️ `cockpit` tab 渲染的是会话 Dashboard,职责不同 |\n\n**与本设计意图的关系**(对齐用户「自动调度为主、废旧手动驾驶舱」的诉求):\n- **旧的「手动调度驾驶舱」在 Web 端已不存在**。`CockpitView` 已是 §8.7 定义的观测型总览,只回答健康/省钱/风险/待办四问题,不做渠道配置编辑,符合「程序自动选、人只观测+有限干预」的方向。无需再废除,只需继续收敛为纯观测。\n- **统一提交入口已存在**(§8.4 快速添加 + §8.5 订阅中心),new-api 集成(§8.5.1)在此之上把「填 baseURL+key」进一步自动化。\n\n**前端演进待办**(本设计新增/明确):\n1. **补 `ManualRoutingIntent` 前端**:这是用户要的「有限偏向性干预」,后端已有、两个前端均未实现(§8.3 试用意图入口尚缺)。\n2. **Desktop 前端对齐 Web**:健康中心/渠道自动托管/管理面板/观测型驾驶舱在 Desktop 几乎空白,需同步或复用 Web 组件,避免长期维护两套。\n3. **管理面板补字段**:暴露 `l2ProbeEnabled`、探测预算、new-api/多来源订阅 adapter 开关(§8.5.2)等,当前只有 3 个字段。\n4. **订阅中心接 §8.5.1/§8.5.2**:新增「new-api」订阅类型与 Provider Adapter 能力矩阵驱动的动态表单。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.5.1 new-api 订阅集成(统一账号接入)\n\n**目标**:用户在订阅中心手填一个 new-api(及其 fork)账号的「系统访问令牌」即可,CCX 自动用该令牌创建代理专用 key、拉取分组倍率、查询额度余额,并把「站点 baseURL + 新建 key」自动落成一个上游渠道,交给 Autopilot 探测与调度。\n\n**范围约束**:\n- 只使用 new-api 用户在「个人设置」生成的 **系统访问令牌(access_token)** 一种凭据;令牌用于 API 调用的身份验证,属敏感数据,须妥善加密存储。\n- **不做** cookie 登录 + 2FA + 导出后台渠道明文 key 的管理员迁移路径(all-api-hub 的 newApiSession 能力),本设计不涉及。\n- new-api 归入 `OriginType=relay`、`OriginTier=second`(中转站),信任等级不因自动化提升;实时质量仍由运行画像决定。\n- 认证令牌像其他订阅凭据一样由用户手动录入,CCX 不代替用户在 new-api 侧注册或登录。\n\n**认证与凭据模型**:\n\nnew-api family 的管理接口鉴权(依据 all-api-hub 传输层实现):\n\n```text\nAuthorization: Bearer # 系统访问令牌;raw 模式可去掉 Bearer 前缀\nNew-API-User: # 当前用户 ID(必带)\nUser-id: # fork 兼容回退(Veloera/voapi/Super-API 等)\nContent-Type: application/json\n```\n\n统一响应信封:`{ \"success\": bool, \"data\": T, \"message\": string }`;解析时先判 `success`,再取 `data`。\n\n**SubscriptionProfile 扩展**:\n\n在 §3.2.2 的 `SubscriptionProfile` 基础上,为可自动接入的订阅新增 provider 集成字段(沿用「默认关闭 + 显式 opt-in」守则):\n\n```go\n// SubscriptionProfile 新增字段(复用 Phase 4 Item 6 的 SubscriptionAutoRefresh 开关)\ntype SubscriptionIntegration struct {\n Kind string `json:\"kind\"` // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | ...\n BaseURL string `json:\"baseUrl\"` // new-api 站点地址(同时作为新建渠道的上游 baseURL)\n AccessToken string `json:\"-\"` // 系统访问令牌;加密存储,不出 API 响应\n UserID string `json:\"userId\"` // New-API-User 头所需\n AuthTokenMode string `json:\"authTokenMode,omitempty\"` // \"bearer\"(默认) | \"raw\"\n // 自动建 key 的模板\n ProvisionKeyName string `json:\"provisionKeyName,omitempty\"` // 默认 \"ccx-autopilot\"\n ProvisionGroup string `json:\"provisionGroup,omitempty\"` // 建 key 时指定分组,空=默认分组\n ProvisionModels []string `json:\"provisionModels,omitempty\"` // model_limits,空=不限制\n // 自动同步产物(回填,只读展示)\n ProvisionedTokenID int `json:\"provisionedTokenId,omitempty\"`\n LinkedChannelUID string `json:\"linkedChannelUid,omitempty\"`\n}\n```\n\n`AccessToken` 必须与现有敏感字段一样走加密存储,序列化到前端时脱敏(仅显示尾部若干位)。\n\n**new-api 接口清单**(本集成实际调用的子集,路径以主线 new-api 为准,字段名保持与上游一致):\n\n| 用途 | 方法 | 路径 | 请求体 | 响应关键字段 |\n|------|------|------|--------|--------------|\n| 校验令牌 + 查用户/余额 | GET | `/api/user/self` | — | `data.id`、`data.username`、`data.quota`(剩余额度)、`data.used_quota` |\n| 拉分组倍率 | GET | `/api/user/self/groups` | — | `data: { : { desc, ratio } }` |\n| 站点全部分组 | GET | `/api/group` | — | `data: string[]` |\n| 账号可用模型 | GET | `/api/user/models` | — | `data: string[]` |\n| 创建代理 key | POST | `/api/token/` | `CreateTokenRequest`(见下) | `data.key` / `success` |\n| key 列表 | GET | `/api/token/?p={p}&size={n}` | — | `data.items[]`(含 `id/key/name/status/remain_quota`) |\n| 更新 key | PUT | `/api/token/` | `{ ...token, id }` | `success` |\n| 删除 key | DELETE | `/api/token/{id}` | — | `success` |\n\n`CreateTokenRequest`:\n\n```json\n{\n \"name\": \"ccx-autopilot\",\n \"remain_quota\": 0,\n \"expired_time\": -1,\n \"unlimited_quota\": true,\n \"model_limits_enabled\": false,\n \"model_limits\": \"\",\n \"allow_ips\": \"\",\n \"group\": \"\"\n}\n```\n\n`quota` 是 new-api 内部额度单位(非美元),换算比例读 `GET /api/status` 的 `price` / `PaymentUSDRate`(无鉴权),或允许用户在订阅中手填换算率;MVP 直接以 quota 原值展示 + 站点换算率估算余额。\n\n**接入流程**(订阅中心「新增订阅 → 类型选 new-api」):\n\n```text\n1. 用户输入:站点 baseURL + 系统访问令牌(access_token) + userId\n (userId 可选自动获取:先用令牌调 /api/user/self 拿 data.id 回填)\n2. 校验:GET /api/user/self\n ├─ success=false / 401 → 令牌无效或 userId 不匹配,报错停止\n └─ 成功 → 记录 username、quota,创建 SubscriptionProfile(provider=new_api, originTier=second)\n3. 拉分组:GET /api/user/self/groups → 写入 GroupMultipliers({group: ratio})\n GET /api/user/models → 记录账号可用模型,供渠道 supportedModels 参考\n4. 建 key:POST /api/token/(按 ProvisionKeyName/Group/Models 模板)\n ├─ 已存在同名 ccx-autopilot key → 复用其 id,不重复创建(列表比对 name)\n └─ 新建成功 → 回填 ProvisionedTokenID,取 data.key 作为渠道 apiKey\n5. 自动建渠道:用 baseURL + 新建 key 创建一个上游渠道\n ├─ channelKind 由用户选择或按站点探测(messages/chat/responses/gemini)\n ├─ status=unknown,autoManaged=true,OriginType=relay\n ├─ 建立 SubscriptionUID → ChannelUID 链接(回填 LinkedChannelUID)\n └─ 触发 §8.4 的 Discovery + 能力测试标准流程\n6. 完成:订阅卡片显示余额、分组倍率、已绑定渠道、探测进度\n```\n\n关键点:new-api 集成**不额外造探测逻辑**,第 5 步之后完全复用现有 §8.4「添加并探测」链路;集成层只负责「令牌 → key + 分组 + 余额 + baseURL」的自动填充,把手填 baseURL/key 的动作自动化掉。\n\n**刷新与同步**(复用 `POST /api/subscriptions/{uid}/refresh`):\n\n| 触发 | 动作 |\n|------|------|\n| 手动刷新余额 | `GET /api/user/self` 更新 `Balance` + `BalanceUpdatedAt` |\n| 定时刷新(可选,opt-in) | 复用 Phase 4 Item 6 `SubscriptionAutoRefresh`;new-api 作为 `relay` provider adapter 纳入,默认关闭 |\n| 分组倍率变更 | 刷新时对比 `/api/user/self/groups`,`GroupMultipliers` 变化 → 更新并提示「套餐倍率已变」 |\n| key 失效检测 | 渠道侧探测到该 key 全 401 → 订阅卡片提示「key 可能已删除/禁用」,提供「重新建 key」一键动作(重跑第 4-5 步,复用旧渠道,只换 apiKey) |\n| 可用模型变更 | `/api/user/models` 结果哈希变化 → 触发对应渠道重新 Discovery |\n\n**安全与边界**:\n- `access_token` 权限等同 new-api 账户,须加密存储、脱敏展示、禁止写入日志;刷新失败连续 N 次自动停用该订阅的自动刷新,只保留手动。\n- 自动建 key 默认 `unlimited_quota=true` 但可由用户改为限额模板;建 key 是有副作用的写操作,须在接入前明确告知用户「将在 new-api 侧创建一个名为 ccx-autopilot 的令牌」。\n- new-api 是 `OriginTier=second`,**不得**用作隐私敏感的 routing advisor / classifier(遵守 §3.2.1 约束)。\n- fork 兼容:请求同时带 `New-API-User` 与 `User-id` 头;响应信封字段名(`success/data/message`)在部分 fork 可能不同,adapter 需容错解析,失败时回退手动录入。\n- 一个 new-api 订阅默认只自动建/绑定一个渠道;用户如需该站多协议渠道,可在同订阅下手动增建,共用同一 key。\n\n### 8.5.2 订阅 Provider Adapter 框架(多来源余额与接入)\n\n§8.5.1 的 new-api 集成不是特例,而是「订阅 Provider Adapter」框架的一个实现。Phase 4 Item 6 已落地 openai/anthropic/google 的余额刷新 adapter,本框架把它抽象成统一接口,让 new-api 及后续来源(GitHub Copilot、Codex、Nvidia、MiniMax、DeepSeek 等)按相同契约接入,避免每加一个来源就散落一套逻辑(DRY)。\n\n**统一接口**:\n\n```go\n// backend-go/internal/autopilot/subscription/adapter.go\ntype ProviderAdapter interface {\n Kind() string // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | \"copilot\" | \"deepseek\" | ...\n\n // 校验凭据,返回账号身份(用户名/账户标识);失败即凭据无效\n Verify(ctx context.Context, cred Credential) (AccountInfo, error)\n // 查余额/额度;不支持返回 ErrUnsupported,由 UI 降级为手动录入\n FetchBalance(ctx context.Context, cred Credential) (Balance, error)\n // 拉分组倍率;仅 new-api 类中转有,官方直连返回 ErrUnsupported\n FetchGroups(ctx context.Context, cred Credential) (map[string]GroupRatio, error)\n // 自动创建代理 key;官方直连通常不支持(key 即用户 API key),返回 ErrUnsupported\n ProvisionKey(ctx context.Context, cred Credential, tmpl KeyTemplate) (ProvisionedKey, error)\n // 可用模型列表;用于渠道 supportedModels 参考\n FetchModels(ctx context.Context, cred Credential) ([]string, error)\n}\n```\n\n每个能力独立、可选:adapter 只实现自己支持的部分,不支持的统一返回 `ErrUnsupported`(接口隔离原则),UI 据此隐藏/降级对应入口。所有 adapter 共用 `SubscriptionAutoRefresh` 的 opt-in 开关与 `Validate()` 兜底,与 Phase 4 现有防御模式一致。\n\n**能力矩阵**(✅ 支持 / ⚠️ 部分或需确认 / ❌ 不支持,走手动):\n\n| 来源 | Kind | 验证 | 查余额 | 拉分组 | 自动建 key | OriginTier |\n|------|------|:----:|:------:|:------:|:----------:|------------|\n| new-api family | `new_api` | ✅ | ✅ quota | ✅ | ✅ | second |\n| OpenAI 官方 | `openai` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Anthropic 官方 | `anthropic` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Google 官方 | `google` | ✅ | ⚠️ | ❌ | ❌ | first |\n| GitHub Copilot | `copilot` | ⚠️ token | ❌ 无额度概念 | ❌ | ❌ | first |\n| Codex plan | `codex` | ⚠️ | ⚠️ 用量窗口 | ❌ | ❌ | first |\n| Nvidia NIM | `nvidia` | ✅ | ⚠️ credits | ❌ | ❌ | first |\n| MiniMax | `minimax` | ✅ | ⚠️ | ❌ | ❌ | first |\n| DeepSeek | `deepseek` | ✅ | ✅ balance API | ❌ | ❌ | first |\n\n说明:官方直连来源普遍**不支持自动建 key**(用户的 API key 本身就是凭据,直接作为渠道 apiKey 录入即可)、也**没有分组倍率**(价格按官方定价表)。它们的 adapter 价值集中在「验证 + 查余额/用量窗口」,把 §3.2.4 UsageWindow 的官方用量 API 来源坐实。DeepSeek 有独立余额查询端点,可完整支持余额。Copilot 属订阅制、无 token 余额概念,只做验证 + 用量提示。\n\n**接入分层**(YAGNI:不强求一次做全,按价值排期):\n\n```text\nP0(本轮):new_api(完整:验证+余额+分组+建key+自动建渠道)\n —— 覆盖用户最主要的中转站场景\nP1:deepseek(验证+余额)、官方三家余额已在 Phase 4 完成 → 补 UsageWindow 对齐\nP2:nvidia / minimax(验证+credits/余额)\nP3:copilot / codex(验证+用量窗口提示,无余额)\n```\n\n每个来源的具体端点在实现时于 `adapter.go` 各 provider 文件内落地,本设计只固定接口契约与能力矩阵,避免文档与上游 API 变动强耦合。新增来源 = 实现 `ProviderAdapter` + 在能力矩阵登记,不改框架。\n\n**能力缺失与降级**:\n- adapter 任一能力返回 `ErrUnsupported` → 订阅中心对应字段回退为手动录入(余额手填、倍率手填),不报错、不阻塞接入。\n- adapter 验证失败/超时 → 订阅可仍以「手动模式」创建,仅失去自动刷新与自动建 key,其余流程(手填 baseURL+key 建渠道)不受影响。\n- 官方来源无「自动建 key」时,接入流程退化为 §8.4 标准快速添加:用户填 API key,adapter 只补验证与余额。\n- 所有自动刷新遵守 `SubscriptionAutoRefresh` 默认关闭;连续失败自动停用自动刷新并在驾驶舱「人工待办」提示。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3,\n \"providerTimePricing\": {\n \"deepseek\": {\n \"effectiveFrom\": \"2026-07-20T00:00:00+08:00\",\n \"timeZone\": \"Asia/Shanghai\",\n \"peakMultiplier\": 2,\n \"peakWindows\": [\n { \"start\": \"09:00\", \"end\": \"12:00\" },\n { \"start\": \"14:00\", \"end\": \"18:00\" }\n ]\n }\n }\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n`providerTimePricing.effectiveFrom` 使用 RFC3339;修改该值即可调整激活时间,设为空字符串则停用对应规则。\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**Phase 3A 状态(2026-07-09)**:低风险、不改真实路由语义的子集已完成并接入真实链路——`Manager.collectAll()` 新旧画像对比产出变更事件(`DetectProfileChanges`)、`ProfileChangelogStore`(环形内存 + SQLite,30 天机会性清理)、`EventHub` 内存 pub/sub、`GET /api/health-center/changelog`(历史)+ `GET /api/health-center/events`(WebSocket 实时推送)、`AutoDiscoveryRunner` 发布 `discovery_completed`/`auto_mapping_applied`、前端 `ProfileChangelogTimeline.vue` 接入健康中心页并支持断线自动重连。均为只读展示,未修改 `SmartRouter`/`EndpointAttemptPolicy` 调度逻辑。落地时顺带发现并修复一个真实缺口:浏览器原生 WebSocket 无法设自定义 header,为此给 `middleware.getAPIKey` 增加了 `Sec-WebSocket-Protocol` 鉴权回退(业界标准做法,仅新增一个 key 来源,不降低现有校验)。\n\n**Phase 3B(已全部完成,2026-07-09)**:模型自动映射、自动恢复探测、晋升/降级机制会直接改变真实调度决策,风险明显高于 3A,四个子项已分批全部完成。§12.2 P1.5 的 SQLite schema version / 版本化 migration / `OriginType`&`OriginTier` 旧配置 backfill / `killSwitch`+按 task class/channel disable **已于 2026-07-09 补强完成**(详见 §12.2 P1.5)。**Phase 3B-1(自动恢复探测补全)已于 2026-07-09 完成**:修复了 L2 探测子系统里此前从未打通的两个真实 bug——① `collectAll()` 每轮把新构造的画像整行 Upsert,会把 `ProbeWorker` 刚写入的 `LastProbeAt`/`ProbeSuccess`/`ConsecutiveProbeSuccess` 等字段清零,导致 6 小时探测冷却期形同虚设(修复:`carryForwardProbeFields` 在 Upsert 前搬运 Probe* 字段,不影响 L1 诊断字段的现有覆盖逻辑);② `extractRawAPIKey` 是返回 `KeyMask` 掩码值的占位 stub,探测请求实际带的是假 key(修复:新增 `APIKeyResolver` 回调 + `Manager.ResolveAPIKey`,通过遍历配置里对应渠道的 `APIKeys` 逐个计算 `KeyHashFromAPIKey` 比对来还原明文;resolver 未注入或未命中时 fail-open 跳过探测且不消耗预算)。同时补齐了 `degraded/limited → healthy` 的连续成功恢复路径(新增 `KeyEndpointProfile.ConsecutiveProbeSuccess` 计数器 + `HealthCheckConfig.ProbeRecoveryThreshold`,默认连续 2 次探测成功才恢复,避免单次探测噪声导致状态抖动;探测失败计数器清零)。**Phase 3B-3(StabilityTier 晋降级滞后)已于 2026-07-09 完成**(提交 664d3fff):在原本每轮从瞬时 1h 窗口重算的无记忆 `StabilityTier` 上加一层连续窗口滞后——新增 `KeyEndpointProfile.EffectiveStabilityTier`/`StabilityPendingTier`/`StabilityPendingStreak` 三字段 + 纯函数 `applyStabilityHysteresis`(对称阈值:连续 N 轮同值才切换生效档,`HealthCheckConfig.StabilityHysteresisWindows` 默认 2;首次画像直接采用,不等待阈值,避免新渠道卡在零值),`AggregateChannelProfile` 改读 `EffectiveStabilityTier`(空则回退 `StabilityTier`)。晋升/降级机制的数据模型仅限于 StabilityTier,不改变 QualityTier/SpeedTier/CostTier 语义。**Phase 3B-2(模型自动映射闭环)已于 2026-07-09 完成**(提交 0f02e988,随后修复一处 critical 集成 bug,提交 856372c4):新建 `ModelProfileStore`(SQLite,`model_profiles` 表)+ 扩展 `AutoDiscoveryRunner` 为每个发现模型写入 `ModelProfile`(仅 `AutoManaged` 渠道);新建 `ModelResolver` 实现 §5.4 五步逻辑(手动 `RedirectModel` 短路 → `GetModelProfiles` → `filterByCapabilityFloor` → `rankBySimilarity`);`scheduler` 包新增 `ModelSupportResolverFunc` hook(fail-open,未注入或返回不支持时严格回退到原 `ExplainModelSupport`);`endpoint_policy.go` 的 `resolveMappedModel`(此前是永不触发的死代码——`profile.ModelMapping` 从未被写入)现调用 `ModelResolver`;`handlers/common` 在 `RedirectModel` 之后应用 `MappedModel` 覆盖请求体,并按 `EchoMappedModel` 配置回显 `X-CCX-Mapped-Model`/`X-CCX-Original-Model`/`X-CCX-Mapping-Source` 响应头。三条件安全门控:`ModelMapping.AutoResolve==true`(**默认由死配置的 true 改为 false**)AND `RoutingMode ∈ {assist, auto}` AND 无 `KillSwitch`,三者任一不满足则所有新增代码路径行为与今天字节级一致;且仅 `AutoManaged` 渠道触发,手动配置渠道完全不受影响。对抗性复核发现并修复一个真实 critical bug:`scoreEndpointForKey` 用空 `channelUID` 计算 `EndpointUID` 并写入 `modelByUID`,而 `handlers` 层用真实 `channelUID` 查 `modelByUID`,两者哈希永不相等导致 `MappedModel` 在 key 级调度路径上完全查不到(提交 856372c4 修复:命中画像后改用 `profile.EndpointUID`,新增回归测试锁定该不变量)。至此 Phase 3B 四个子项(自动恢复探测、StabilityTier 滞后、模型自动映射、active filter 前置判定)全部完成;SLO regression 自动回滚仍是独立子系统,留待后续单独立项。\n\n**范围**:\n- [x] 运行时指标驱动画像实时更新(Phase 3A:`collectAll()` 每轮 diff + changelog + WebSocket 推送)\n- [x] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)—— Phase 3B-2(2026-07-09 完成,提交 0f02e988 + 856372c4)\n- [x] active model filter / context filter 支持自动映射前置判定 —— Phase 3B-2:`scheduler.ModelSupportResolverFunc` fail-open 接入 `select.go`\n- [x] 自动恢复探测(limited/dead → healthy)—— Phase 3B-1(2026-07-09 完成):`APIKeyResolver` + `carryForwardProbeFields` + `ConsecutiveProbeSuccess`/`ProbeRecoveryThreshold`\n- [x] 晋升/降级机制(连续成功→升级,连续失败→降级)—— Phase 3B-3(2026-07-09 完成,提交 664d3fff):`StabilityTier` 连续窗口滞后(`EffectiveStabilityTier`),暂不含 QualityTier/SpeedTier/CostTier\n- [x] WebSocket 推送画像变更事件(Phase 3A:`GET /api/health-center/events`)\n- [x] 前端画像变更历史/时间线(Phase 3A:`ProfileChangelogTimeline.vue`)\n\n**预估工期**:2-3 周(3A、3B-1、3B-2、3B-3 均已完成;advisor 与全局 auto 的 SLO 回滚均已补齐)\n\n> 状态校正(2026-07-17):上方 Phase 3B 历史说明中的“SLO regression 自动回滚待单独立项”已完成;advisor 回滚与全局 auto 上线闸门/自动降级是两个独立保护层。\n\n### Phase 4:高级特性\n\n**Phase 4 状态(2026-07-09,全部完成)**:8 项全部实现并接入真实链路,均遵循\"默认关闭 + 显式 opt-in\"安全守则。\n\n**范围**:\n- [x] 多维度标签系统扩展(用户自定义标签)——Item 1\n- [x] 成本报表:按用户/模型/渠道/key 统计真实有效成本——Item 2(新增 `proxyKeyMask` 身份贯穿链路:`middleware.GetProxyKeyMask` → `ChannelLog`/`RequestRecord` → SQLite `proxy_key_mask` 列,供 Item 4 复用)\n- [x] Trusted advisor SLO regression 自动回滚——Item 3(默认关闭;`SLORollbackConfig`,连续 degrading 窗口数默认 3,不做自动恢复以避免震荡)\n- [x] 渠道推荐:根据使用模式推荐新渠道——Item 4\n- [x] 批量渠道管理(导入/导出/模板)——Item 5\n- [x] 订阅中心 provider adapter:可选自动刷新余额/套餐状态——Item 6(默认关闭;仅 openai/anthropic/google 且显式填写 BillingAPIKey 的订阅生效,中转/公益渠道继续手动维护)\n- [x] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板——Item 7\n- [x] A/B 测试:低比例统计抽样双发——Item 8(默认关闭,默认 1% 抽样 + 每小时 60 次硬预算;主请求路径不受影响,影子请求在主响应返回后异步发起)\n\n**关键实现说明**:\n- Item 3/6/8 均在 `AutopilotRoutingConfig` 新增独立配置块(`SLORollback`/`SubscriptionAutoRefresh`/`ABTest`),`Validate()` 统一做负值/越界兜底,保持与既有字段一致的防御模式。\n- Item 4 直接复用 Item 2 的 `proxyKeyMask` 身份标记,未重新发明身份识别机制。\n- **全局 auto 安全闸门(2026-07-17)**:请求终态按 15 分钟窗口无偏聚合,详细 trace 继续抽样;管理 API 只在 shadow/assist 已积累至少 500 个有效请求、24 小时连续观测且成功率/fallback/fail-open/p95 均达标时允许切换 `auto`。后台连续检测三个已完成 auto 窗口,相对最近七天安全模式基线持续恶化时,持久化降级到 `assist` 并记录 `autopilot_auto_safety_events`,不自动恢复。\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- ~~SQLite schema version 和幂等 migration。~~ **已完成**:`ensureSchemaVersion` 基于 `PRAGMA user_version`,当前 7 张表定版为 v1 基线;库版本高于代码版本时返回 error(fail-closed,不做任何写操作),交给下游 fail-open 分支接管。\n- ~~旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。~~ **已完成**:`ensureOriginBackfill` 只补 `\"unknown\"`,不做任何基于 URL/名称的猜测推断;已接线到 `Profiler.DeriveEndpointProfile`,`BreakTieByOriginTier` 现在能吃到真实数据(此前一直是 fallback 的 `unknown`)。\n- ~~profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。~~ **已确认满足并测试锁定**:`main.go` 现有结构本就是 fail-open(`NewProfileStore`/`NewManager` 出错只打日志、`autopilotManager` 保持 `nil`,下游全部有 `nil` 守卫),本次新增 `schema_migration_test.go` 用测试锁定这条不变量。\n- ~~全局 `killSwitch`、按 task class disable、按 channel disable。~~ **已完成**:新增 `AutopilotRoutingConfig.DisabledTaskClasses`/`DisabledChannelUIDs`;`CandidateFilterFor`/`executeFilter`/`BuildPlan` 三处一致接入,`smart_router_invariant_test.go` 覆盖\"task class 禁用后回退默认调度\"\"channel 禁用后不出现在候选里\"\"dry-run 与真实路径行为一致\"三条不变量。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。**部分完成**:`ReleaseController.EvaluateAndApplyRegression` 已实现三窗口回归检测和 `SafetyOverride` 降级机制;`RoutingModeActive` 已加入配置枚举和 `IsAutopilotActive`。\n\n#### P1.6 Trace v2 契约与灰度发布\n\n> 对应实施计划:`docs/superpowers/plans/2026-07-23-autopilot-trace-rollout.md`\n\n**Trace v2 核心变更:**\n\n- `RoutingDecisionTrace` 增加 `SchemaVersion`、`TraceRevision`、`RequestCorrelationId`、`ReleaseID`、`PolicyFingerprint`、`TargetMode`/`EffectiveMode`/`Cohort`、`SchedulerDecision`、`EndpointAttempts` 字段。\n- 新增 `TraceDetailV2`、`TraceSummary`、`SchedulerDecisionSummary`、`EndpointAttemptSummary` DTO;使用显式 DTO 落盘而非直接 JSON 化运行时结构。\n- `ComparisonStatus` 三态枚举(`matched`/`mismatched`/`uncompared`)替代裸 `bool`。\n- `SanitizeForPersistence` 与 `SanitizeForResponse` 双边界脱敏;`GenerateTraceUIDv2` 使用 `crypto/rand` 碰撞安全生成。\n- `RoutingReleaseSnapshot` 不可变发布快照,入口冻结后续只读。\n\n**SQLite v6 迁移:**\n\n- trace 表新增列:`schema_version`、`trace_revision`、`request_correlation_id`、`release_id`、`policy_fingerprint`、`persistence_class`、`details_json`。\n- 窗口表重建主键:增加 `release_id`/`policy_fingerprint`/`cohort` 维度,新增 `compared_count`/`matched_count`/`mismatch_count`/`uncompared_count` 全量比较计数。\n- safety event 表增加 `release_id`/`policy_fingerprint` 字段。\n\n**灰度发布状态机:**\n\n```text\noff --管理员启用--> shadow --门槛满足--> assist --门槛满足--> auto --100% 稳定--> active\n```\n\n| 状态 | 行为 | 放量规则 |\n|---|---|---|\n| `off` | 不调用 Autopilot | rolloutPercent=0 |\n| `shadow` | 计算+记录,不影响调度 | rolloutPercent=0,安全基线 |\n| `assist` | 重排候选,不删除 | rolloutPercent 1→100 |\n| `auto` | 硬约束过滤+重排 | rolloutPercent 1→100 |\n| `active` | 与 auto 语义相同 | rolloutPercent=100,1% shadow control |\n\n**ReleaseController**(`release_controller.go`):\n\n- 集中处理相邻状态迁移校验(逐级晋升,降级随时允许)。\n- `ComputeCohort` 使用 session ID / request correlation ID 与 `rolloutSeed` 做稳定哈希分桶。\n- `EvaluateAndApplyRegression` 检查三窗口回归并应用 `SafetyOverride`。\n- `AllowedTransition` 禁止跳级,`modeRank` 确定顺序。\n\n**只读 Trace API:**\n\n| 接口 | 返回 | 约束 |\n|---|---|---|\n| `GET /api/autopilot/traces` | `TraceSummary[]` | 游标分页、release/cohort/mode 过滤、partial 标记 |\n| `GET /api/autopilot/traces/:traceUid` | `TraceDetailV2` | 404=未找到/已过期/未采样;503=存储不可用 |\n| `GET /api/autopilot/traces/stats` | `TraceStatsResponse` | 三态比较统计、Mode/TaskClass 分布 |\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | shadow 样本/准确率/critical misroute 门槛;active 后 SLO regression 自动 rollback;全局 kill switch |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "53edf533db8f9b9d31f70a8b4957c0b7f1fa8e42": {"t": 1785759336.0169923, "n": 167257, "h": "4176b33e0cccefab", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.2.3 同订阅多 Key:能力共享与配额负载均衡\n\n**问题**:官方订阅 plan(Claude Max、Codex plan 等)允许用户持有多个 key,或购买多份相同套餐。若每个 endpoint 独立探测与配置,探测预算被浪费、配置重复;请求量大时还需要在同 plan 的多个 key 之间按官方 RPM 限速自动负载均衡,而不是打满第一个 key 才溢出到下一个。\n\n**能力检测去重(同一套餐只需一套能力检测和配置)**:\n\n```text\nSubscriptionUID 相同的 endpoint 共享一份「订阅级能力画像」:\n 模型列表 / vision / tool / reasoning 支持 / 协议兼容开关 → 只探测一次,\n 存在 SubscriptionProfile.SharedCapability,endpoint 画像以引用方式继承。\n\n仍然 per-endpoint 维护的:健康状态、延迟、熔断、用量窗口\n (key 可能被单独封禁/限速,能力相同不代表运行态相同)\n\n继承失效条件:某 key 实测模型列表哈希与订阅级不一致(GroupChangeDetector 信号)\n → 该 key 脱离继承、独立探测,并在 UI 标记「与套餐能力不一致」警告。\n```\n\n**配额负载均衡(Phase 1 shadow 展示,Phase 2 生效)**:\n\n```text\n每 key 维护配额余量视图:\n remainingRPM = officialRPM − 当前窗口已用\n officialRPM 来源优先级:内置 manifest > 用户配置 > RateLimitDiscoverer 建议\n\n同订阅多 key 时按「剩余余量加权」分配请求(余量大者优先、\n窗口最早重置者兜底),替代顺序打满:\n 仅当渠道 autoManaged 且同一 SubscriptionUID 下 key 数 > 1 时启用;\n 否则保持现有 sequential/random key 顺序策略不变。\n\n某 key 的窗口配额耗尽 → 临时降权并切到同订阅其他 key,\n不熔断整个渠道(这是配额耗尽,不是故障)。\n```\n\n### 3.2.4 订阅用量窗口 (Usage Windows)\n\n部分官方 plan 提供 5h / 日 / 周 / 月等窗口的用量查询与限制(如 Claude 订阅的 5h 滚动窗口 + 周配额)。在 key 的位置展示这些窗口,帮助用户掌握使用情况:\n\n```go\ntype UsageWindow struct {\n Window string `json:\"window\"` // \"5h\" | \"day\" | \"week\" | \"month\"\n Used float64 `json:\"used\"` // 已用量\n Limit float64 `json:\"limit\"` // 上限,0 = 未知\n Unit string `json:\"unit\"` // requests | tokens | credits | percent\n ResetsAt time.Time `json:\"resetsAt\"` // 窗口重置时间\n Source string `json:\"source\"` // official_api | response_header | local_metering\n FetchedAt time.Time `json:\"fetchedAt\"`\n}\n// KeyEndpointProfile 与 SubscriptionProfile 均增加 UsageWindows []UsageWindow\n```\n\n数据来源优先级:\n\n```text\n1. 官方用量 API:有 provider adapter 的 plan 主动拉取(§Phase 4 订阅 adapter 的可提前子集)\n2. 响应头:anthropic-ratelimit-* / x-ratelimit-* 等(复用 RateLimitDiscoverer 的信号 tap)\n3. 本地计量兜底:CCX 按 key 累计请求数/token(误差来自窗口起点对齐,标注 Source=local_metering)\n```\n\nUI 要求:key 行内显示主窗口(5h)用量进度条,悬浮展开全部窗口;用量 >80% 黄色预警、超限红色并显示预计重置时间。调度联动(Phase 2):窗口余量进入配额负载均衡权重,窗口耗尽的 key 临时降权而非熔断。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n#### 4.2.1 内置模型清单 (Builtin Models Manifest)\n\n**问题**:部分官方订阅 plan 的入口没有正确的 models 接口——要么不存在(404)、要么需要额外权限、要么返回与订阅实际可用模型不符的列表(典型:Claude 订阅 OAuth 入口、Codex plan 入口、部分官方企业网关)。对这些 baseURL 做模型发现会失败或产出错误画像。\n\n**方案**:CCX 内置「已知官方 baseURL → 模型清单」映射表,命中时直接返回内置内容,完全跳过上游 models 接口:\n\n```go\n// backend-go/internal/config/builtin_models_manifest.go(生成式,随模型注册表更新)\ntype BuiltinModelsManifest struct {\n BaseURLPattern string `json:\"baseUrlPattern\"` // 精确 host 或前缀匹配,如 \"api.anthropic.com\"\n ServiceType string `json:\"serviceType\"` // messages/responses/…\n PlanHint string `json:\"planHint,omitempty\"` // 订阅类型提示(如 \"claude_subscription\")\n ModelIDs []string `json:\"modelIds\"` // 该入口实际可用的模型清单\n DisableProbe bool `json:\"disableProbe\"` // true 时 Discovery 跳过 GET /v1/models\n}\n```\n\n**两个生效位置**:\n\n```text\n1. Discovery(§4.2 流程第 2 步之前):\n baseURL 命中 manifest 且 DisableProbe=true\n → 跳过 GET /v1/models,直接用 ModelIDs 生成 ModelProfile[]\n → ProfileSource = \"builtin_manifest\",不再因 models 接口失败而误判 misconfigured\n\n2. /v1/models 代理响应(现有 Models API):\n 客户端向 CCX 请求 /v1/models 时,聚合结果中该渠道的贡献\n 直接取内置清单,不向上游发起 models 请求\n (渠道显式配置了 supportedModels 时仍以用户配置优先)\n```\n\n**维护与覆盖顺序**:\n\n```text\n渠道显式 supportedModels(用户手动) > 内置 manifest > 上游 models 接口实探 > 内置候选模型回退\n```\n\nmanifest 与模型注册表同源生成(`generated_model_registry.go` 同级),新模型发布时随注册表一起更新;HealthAnalyzer 对命中 manifest 的 endpoint 不将「models 接口 404/403」计入 misconfigured 信号。\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n MappedEffort EffortLevel // 自动映射的 effort 级别(空=未决定)\n MappedEffortDecided bool // effort 是否由 Autopilot 决定\n EndpointUID string // 稳定 endpoint ID\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n\n // ResolvedModelByEndpointUID(旧 API):仅返回模型名,向后兼容。\n // ResolvedTargetByEndpointUID(新 API):返回 model + effort 原子决策。\n // 两者从同一份 targetByUID 派生,避免两个真值源。\n ResolvedModelByEndpointUID func(endpointUID string) string\n ResolvedTargetByEndpointUID func(endpointUID string) *ResolvedRouteTarget\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20;assist 下 dead/misconfigured 额外 -100 并保留在队尾\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法细分时进入 `general`;有规范 Knowledge 基准则使用其软评分,缺少直接证据时仍取 0.5 中性值。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n**手动 L3 第一版状态(2026-07-15)**:已实现 `pq-v1-20260715` 固定 JSON canary,覆盖\nMessages、Chat Completions、Responses 与 Gemini 上游协议。调用方只能指定 `endpointUid`、\n`modelId` 和 1~3 次重复数,不能注入 prompt。评分仍按 40% 完整性、30% 语义正确性、\n15% 严格 JSON 格式、15% 延迟执行;第一版使用非流式完整响应延迟作为首 token 延迟代理,\n后续引入流式采样时再替换该维度。单次有效样本 confidence=0.6,最多三次提升到 0.8;\n失败样本按 0 分计入均值,全部失败不写画像。\n\n安全边界:仅管理 API 手动触发,不启动自动 worker;当前进程按 UTC 日设置 12 次硬预算,\n请求前原子预留,单次 90 秒超时、响应体上限 64 KiB。同一 endpoint×model 不并发执行;\n单次请求最多允许 2048 个输出 token,以容纳推理模型的隐藏思考并限制最坏额度消耗。\n对声明可控推理的模型固定采用最低可用 effort;Claude 兼容端点若只暴露\n`reasoning_content` 而未声明 thinking 模式,则与 capability-test 一致显式关闭 thinking。\n原始模型输出和明文 Key 既不落盘也不返回,只保存分数、canary 版本、置信度和脱敏判定。\n`user_feedback` 仍高于 `probe`,自动发现重建 ModelProfile 时必须保留既有质量证据。\n跨进程共享预算与 LLM-as-judge/语义向量评分仍属于后续阶段。\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设 + 自定义\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n| `custom` | 用户自定义 | 用户自定义 | 高级用户微调,范围 0.0 ~ 3.0 |\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法细分的通用任务;缺少基准证据时中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general;有规范 Knowledge 基准时使用其软评分,缺少直接证据时取 0.5 中性。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? canonicalBenchmark[model][domain] * providerQualityFactor\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 规范模型基准 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n#### 5.7.4 规范模型能力基准\n\n`shared/model-registry/ccx_model_registry.json` 的 `benchmarkProfiles` 独立保存规范模型能力上界,不与 `upstreamCapabilities` 混合。后者仍是 context、vision、tool calls 等硬能力的事实源;benchmark 只参与 Autopilot 软评分和 trace 解释,绝不改变 `supportedModels` 或 CapabilityFloor。\n\n```text\nEffectiveCapability[domain]\n = CanonicalCeiling[model, domain]\n × ProviderQualityFactor[endpoint]\n\nProviderQualityFactor = 1 - confidence × (1 - providerQualityScore)\n无渠道质量证据或 confidence<0.5 时 factor=1;可信证据只能从规范模型上界向下折算。\n```\n\n基准数据必须保留领域向量、来源、核验日期、lane、共享结果数和可比类别覆盖率,不能只保存站点 Overall。当前 BenchLM 接入为 `provisional`:Claude Opus 4.8、GPT-5.6 Terra、GPT-5.6 Sol 各有 30 个共享结果,8 类中 5 类可比,核验日期为 2026-07-13。\n\n领域映射:Knowledge → `general`,Math → `reasoning`,Coding → `coding` / `code_review`,Agentic → `agentic`,Multimodal → `aesthetics_ui`(仅弱代理,解释置信度减半)。Writing / Translation 没有直接类别证据时继续回退家族种子或 0.5。BenchLM 的 cohort 归一化、缺失项插补和外部校准决定了 Overall 不是绝对能力值,因此路由只消费可映射的原始类别分。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n#### 5.8.4 联合决策的现状实现(model + effort 原子化)\n\n> 状态说明(2026-07-26):本节描述的联合决策链路已实现并接入真实请求路径(关联计划 [2026-07-26-autopilot-model-effort-joint-routing.md](../superpowers/plans/2026-07-26-autopilot-model-effort-joint-routing.md) 的 Task 1~7)。**渠道级手工 `modelMapping` / `reasoningMapping` 表目前仍然存在且仍然生效**,退役是该计划的 Task 8,尚未执行;手工表当前的优先级关系见下文。\n\n**决策对象。** 自动路由的返回值不再是裸模型名,而是 `autopilot.ResolvedRouteTarget`(`backend-go/internal/autopilot/route_target.go`):\n\n```go\ntype ResolvedRouteTarget struct {\n Model string // 目标模型 ID\n Effort EffortLevel // 目标思考档位;空串表示不改写\n EffortDecided bool // true 表示 effort 由 Autopilot 决定;false 为 passthrough\n Reason string // 决策原因,用于 Trace 与日志\n}\n```\n\n`EffortDecided=false` 且 `Effort` 为空是合法的 fail-open 结果:模型可能被改写,但思考参数保持原样。\n\n**决策发生的位置。** `autopilot/endpoint_policy.go` 的 `resolveMappedModel()` 是统一入口,内部分两路:`resolveManualMapping()` 处理渠道级显式 `ModelMapping`(仍是当前优先级更高的一路,返回的 `ResolvedRouteTarget.EffortDecided` 恒为 `false`,即手工映射只改模型不改档位);`resolveAutoModel()` 调用 `ModelResolver` 做自动模型解析,并在 `model_resolver.go` 的 `rankEligibleModels()` 中按 `SupportedEffortLevels` × `PerTaskClass` 展开 `(model, effort)` 候选、应用 `EffortQualityBonus` 与 anti-effort-inflation 反膨胀 tiebreak(同质量档优先选低 effort)、并按 `EffortFloor` 过滤掉低于任务下界的候选。产出的 `ResolvedRouteTarget` 经 `buildResolvedTargetLookup()` 挂到 `EndpointAttemptPolicy.ResolvedTargetByEndpointUID`,与旧的 `ResolvedModelByEndpointUID`(仅模型,向后兼容)并列。\n\n**决策如何到达上游请求。** `handlers/common/upstream_failover.go` 的 `TryUpstreamWithAllKeys` 在为每个 endpoint 尝试构建请求前,通过 `ResolvedTargetByEndpointUID` 取得 `*ResolvedRouteTarget`,交给 `atomicModelEffortRewrite()` 做一次原子改写:先用 `sjson` 改写 `model` 字段;若 `EffortDecided=true` 且 `Effort` 非空,再检查目标模型的 `ThinkingMode`——`adaptive_only` 的模型跳过思考参数注入(模型自身决定思考深度,守卫逻辑对齐 `providers/claude.go` 的 `applyClaudeReasoningEffort`),否则调用 `config.ApplyReasoningParamStyle()` 写入。模型改写与档位改写共用一次函数调用,任一步 `sjson`/`json` 处理失败即整体放弃、保持原始 body,不会出现\"模型改了、档位没改\"的中间态。\n\n**各协议的注入形态。** `config.ApplyReasoningParamStyle(req, style, effort)`(`config/config_utils.go`)按 `upstream.ReasoningParamStyle` 三态分支:\n\n```text\nstyle=\"thinking\" → Claude 派系:写 thinking.type=enabled + thinking.effort=;\n effort 为 off/none 时写 thinking.type=disabled;同时清理互斥字段\n reasoning / reasoning_effort / thinking.budget_tokens\nstyle=\"reasoning_effort\" → OpenAI Chat 派系:写顶层 reasoning_effort 字段,清理 reasoning\nstyle=\"\" / 默认 → Responses 派系:写 reasoning.effort 对象形态\n```\n\n**Gemini 派系是尚未接通的缺口。** 请求侧提取(`extractGeminiEffortExplicit()`,`handlers/common/reasoning_log.go`)已经能读取 `generationConfig.thinkingConfig.thinkingLevel` 并识别显式 `none`(`thinkingBudget=0`),这条读路径用于日志与 `RequestProfile.ClientEffort` 提取。但写路径——把联合决策的 `target.Effort` 写回 Gemini 请求体的 `thinkingConfig.thinkingLevel`——目前代码中不存在:`atomicModelEffortRewrite()` 唯一的写出口是 `ApplyReasoningParamStyle()`,它只覆盖 Claude/Chat/Responses 三种形态,没有 Gemini 分支;`handlers/gemini/handler.go` 的 `buildProviderRequest()` 也没有调用任何 effort 注入函数。也就是说,Gemini 渠道当前仍会经过 endpoint 级模型改写(`atomicModelEffortRewrite` 会改写 `model` 字段本身),但联合决策选中的思考档位不会落到 Gemini 请求体上,效果等同于 fail-open passthrough。此项在原计划里归属 Task 5 的\"Gemini 派系注入路径\",尚未实现。\n\n**可观测性字段。** 原子改写点会向 `gin.Context` 写入两个字段供 ChannelLog 使用:`effortDecisionSource`(`autopilot` 表示由联合决策产出,`passthrough` 表示未改写或手工映射路径)、`effortClampedByClient`(当客户端显式声明的档位低于 Autopilot 选择的档位时置 `true`,由 `isEffortClampedByClient()` 基于 `EffortLevelOrdinal` 比较得出)。\n\n**手工映射表的现状。** `resolveMappedModel()` 当前的分支顺序是\"显式 `ModelMapping` 优先,自动决策兜底\"——与原计划 Task 7 描述的\"反转优先级\"目标相反的方向仍是默认值;反转开关已作为可配置项落地(提交 `d15e776e`),但默认状态未切换,`ModelMapping` / `ReasoningMapping` 两张表本身尚未删除,`config.ResolveReasoningEffort()`(`config/config_utils.go`)与 provider 层(如 `providers/claude.go`)仍会读取 `ReasoningMapping` 独立生效。表的整体退役是 Task 8 的范围,未开始。\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**请求意图边界(ModelRoutingPolicy)**\n\n跨模型替代不是所有模型的默认能力,只对白名单客户端入口开放:\n\n```text\nmessages:\n fable / opus / sonnet / haiku 及对应 claude-* 完整模型名 → adaptive\nresponses:\n gpt-5.6* / gpt-5.5 / gpt-5.4* / codex-auto-review → adaptive\n其他协议或模型:\n exact-only,只能在探测到相同规范化模型 ID 的 endpoint 之间调度\n```\n\n`adaptive` 允许在满足 Capability Floor 后选择 GLM、MiMo 等其他模型族;`exact-only` 禁止跨模型替代。用户显式配置的 `modelMapping` 仍优先于该策略。调度预览和 endpoint 实际解析必须调用同一分类函数,避免诊断结果与真实请求分叉。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ModelProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选优\n // 上下文窗口只负责准入,不对更大的窗口扣分。\n // 排序优先级:\n // a. 模型质量档越高越优先\n // b. provider 已确认的同档模型能力顺序\n // c. 带置信度折算的供应商实测质量\n // d. 探测延迟\n // e. 同渠道/provider 的模型相对消耗倍率\n // 倍率相同或缺失时再比较公开模型成本\n // f. 同模型族与 model ID 仅作为确定性兜底\n best := rankEligibleModels(eligible, requestModel)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n同渠道套餐倍率与公开 USD/Token 价格是不同单位,禁止直接混算。模型解析器先比较 provider\n提供的相对消耗倍率;只有双方倍率相同,或双方均无倍率时,才回退到统一输入/输出规模下的\n公开价格。优云智算当前内置倍率如下(数字越小越省,`glm-5.2` 的 2 次为限时值):\n\n| 模型 | 每次调用套餐消耗 |\n|------|-----------------:|\n| `glm-5.2` | 2 |\n| `glm-5.1` | 6 |\n| `MiniMax-M2.7` | 2 |\n| `kimi-k2.6` | 5 |\n| `deepseek-ai/DeepSeek-V3.2` | 1 |\n| `deepseek-v4-flash` | 1 |\n\nprovider 可以额外声明同一质量档内已确认的模型能力顺序,但只有该档全部候选都命中该元数据\n时才比较,避免把未知模型误判为弱模型,也保证排序不依赖候选输入顺序。优云模板当前记录\n`glm-5.1 > kimi-k2.6`,因此前者即使消耗 6 次,也会在后者的 5 次成本证据之前胜出。\n`glm-5.2` 即使消耗 2 次,也仍会在质量档高于 DeepSeek 候选时优先;V3.2 与 V4 Flash\n同为 1 次时,再由公开价格及最终确定性规则区分。静态 `modelMapping` 仅保留为用户显式覆盖,\n不承担自动替代策略。\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - autoManaged 的空 supportedModels 不表示支持全部,必须由 endpoint ModelProfile 裁决\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n当 ModelResolver 已正常初始化且路由处于 `assist/auto + autoResolve=true` 时,autoManaged 渠道的画像拒绝是权威结果,调度器不得再 fail-open 到空 `supportedModels` 的兼容语义。Resolver 不可用、开关关闭或 `off/shadow` 时仍保留原有 fail-open,保证急停和降级路径不依赖画像库。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n### 6.9 渠道兼容性统一自学习\n\n渠道级兼容性配置不应要求用户先踩坑、再去翻设置面板找开关。这一节描述如何把这些开关从\n「静态布尔」转为「运行时学习到的能力事实」。\n\n**优先级合成**(`config.resolveCompatSwitch`)\n\n```text\n最终生效值 = 用户当前显式设置 ?? 学习结论 ?? 手工配置升级来的种子 ?? 静态默认\n```\n\n裸 `bool` 无法区分「用户显式关闭」与「用户没设置」,因此六个兼容性字段\n(`NormalizeNonstandardChatRoles`、`StripImageGenerationTool`、`CodexNativeToolPassthrough`、\n`StripEmptyTextBlocks`、`PassbackReasoningContent`、`PassbackThinkingBlocks`)均迁移为 `*bool`。\n\n**手工配置升级合并**(`migrateManualCompatSwitchesToSeeds`):目标是「用户此后不需要再手工干预」。\n手工开关若永久压过自动学习,老用户仍要人工维护这些开关、上游能力变化后还得再改一次。因此启动时\n一次性把历史手工值搬到 `UpstreamConfig.CompatSeeds` 并将原字段置 `nil`:\n\n- 种子是**渠道级、无 TTL**(用户意图不会过期),学习记忆是**渠道-Key-模型级、TTL 24h**(上游能力会变)。\n 二者必须分开存,否则种子 24h 后凭空消失,用户原本的判断被静默丢弃。\n- 迁移保留 `false` 值:用户「显式关闭」同样是他观察到的行为证据。\n- 迁移不改变可感知行为(学习缺失时按种子生效),但学到真实结论后自动让位。\n- 幂等:已有种子的渠道跳过,用户之后重新手工设置的值保持第 1 优先级,直到他自己清空。\n- 自动托管渠道在 `RuntimeUpstreamForAutoManagedProvider` 里连种子一起清空,其兼容性完全由\n 厂商原生默认值加运行时学习决定。\n\n注意:`RuntimeUpstreamForAutoManagedProvider` 清理自动托管渠道的手工配置时必须置 `nil` 而非\n`false`,否则会被当作「用户显式关闭」,使自动托管渠道永远学不会兼容改写。\n\n**记忆载体**:`config.ChannelCompatCache`,落盘 `.config/channel_compat.json`,\n键 `channelUID:keyHash:model`,TTL 24h(与 `DeprecatedParamCache`/`SystemHeaderFilterCache` 一致)。\n`Record` 仅在结论新增或翻转时返回 `true`,调用方据此决定是否重试,防止记忆生效后死循环。\n\n**三条学习路径**(按上游信号强度区分,不可一概而论)\n\n| 信号类型 | 兼容项 | 学习方式 |\n| --- | --- | --- |\n| 硬 400/422 + 可正则匹配 | developer role 降级、Codex 专属工具剥离、历史 thinking 回传、图片工具剥离 | 错误驱动:`CompatTraitFromError` 识别 → 记忆 → 同 Key 立即重试 |\n| 无报错,仅响应形态差异 | `passbackReasoningContent`、`stripEmptyTextBlocks` | 探针驱动:首次遇到该组合时 singleflight 后台探测一次 |\n| 厂商文档已公布约束 | Kimi 固定值参数、`tool_choice`、`thinking` | 前置规避:`ApplyKnownParamConstraints` 发送前直接改写,无需失败一次 |\n\n**分层职责**:`upstream_failover` 负责识别信号、写缓存、编排重试、把结论注入 `upstreamCopy`;\n各 provider 在构造上游请求时读取合成值并执行改写。这样 developer role 降级发生在\nResponses→Chat 转换**之后**的 `reqMap` 上,避开「failover 层拿到的是 Responses `input` 而非\nChat `messages`」这个陷阱。\n\n**防误判约束**(学错并持久化比不学更糟)\n\n- 双条件门控:正则命中之外,还要求请求侧确实携带对应结构(`BodyHasDeveloperRole` 等)\n- 单次重试上限:`Record` 返回 `false` 即不再重试\n- 不跨维度污染:结论不外溢到同渠道其他 Key/模型\n- 仅 400/422 参与学习;429/5xx/超时属容量问题,不是能力问题\n- fail-open:缓存损坏、探测失败一律退化为静态默认,不阻断代理请求\n\n**图片工具归因修正**:图片生成受限时,原实现直接拉黑 (Key,模型)。现改为先学习\n「需剥离图片工具」并重试修复请求,只有剥离后仍失败才回落拉黑——从「惩罚渠道」改为「先修复请求」。\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\nGET /api/health-center/provider-quality/budget → 查看本进程手动 L3 每日预算\nPOST /api/health-center/provider-quality/probe → 对指定 endpoint×model 执行固定质量 canary\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n#### 8.0.1 实现现状与演进方向(2026-07-09 核实)\n\n四界面在 **Web 前端(`frontend/src`)已大面积落地**,不是纯文档:\n\n| 界面 | Web 实现 | Desktop 实现 |\n|------|----------|--------------|\n| 健康中心 | ✅ `HealthCenterView.vue` + `HealthCenterStats`/`HealthChannelTable`/`HealthChannelDetail`/`ProfileChangelogTimeline`,路由 `/health` | ❌ 无 |\n| 订阅中心 | ✅ `SubscriptionsView.vue` + `SubscriptionPlanTable`,CRUD + 刷新余额,路由 `/subscriptions` | ⚠️ 独立旧实现 `subscriptions/SubscriptionTab.vue`(与 Web 两套代码) |\n| 渠道中心 | ✅ `ChannelOrchestration.vue`(六态 `ChannelHealthBadge`、来源 chips、`autoManaged` 图标齐备) | ❌ 未同步 |\n| 管理面板 | ⚠️ `AutopilotModePanel.vue` 仅暴露 mode/killSwitch/costPreference | ❌ 无 |\n| 驾驶舱 | ✅ `CockpitView.vue`(观测型总览:健康统计+余额汇总+渠道推荐) | ⚠️ `cockpit` tab 渲染的是会话 Dashboard,职责不同 |\n\n**与本设计意图的关系**(对齐用户「自动调度为主、废旧手动驾驶舱」的诉求):\n- **旧的「手动调度驾驶舱」在 Web 端已不存在**。`CockpitView` 已是 §8.7 定义的观测型总览,只回答健康/省钱/风险/待办四问题,不做渠道配置编辑,符合「程序自动选、人只观测+有限干预」的方向。无需再废除,只需继续收敛为纯观测。\n- **统一提交入口已存在**(§8.4 快速添加 + §8.5 订阅中心),new-api 集成(§8.5.1)在此之上把「填 baseURL+key」进一步自动化。\n\n**前端演进待办**(本设计新增/明确):\n1. **补 `ManualRoutingIntent` 前端**:这是用户要的「有限偏向性干预」,后端已有、两个前端均未实现(§8.3 试用意图入口尚缺)。\n2. **Desktop 前端对齐 Web**:健康中心/渠道自动托管/管理面板/观测型驾驶舱在 Desktop 几乎空白,需同步或复用 Web 组件,避免长期维护两套。\n3. **管理面板补字段**:暴露 `l2ProbeEnabled`、探测预算、new-api/多来源订阅 adapter 开关(§8.5.2)等,当前只有 3 个字段。\n4. **订阅中心接 §8.5.1/§8.5.2**:新增「new-api」订阅类型与 Provider Adapter 能力矩阵驱动的动态表单。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.5.1 new-api 订阅集成(统一账号接入)\n\n**目标**:用户在订阅中心手填一个 new-api(及其 fork)账号的「系统访问令牌」即可,CCX 自动用该令牌创建代理专用 key、拉取分组倍率、查询额度余额,并把「站点 baseURL + 新建 key」自动落成一个上游渠道,交给 Autopilot 探测与调度。\n\n**范围约束**:\n- 只使用 new-api 用户在「个人设置」生成的 **系统访问令牌(access_token)** 一种凭据;令牌用于 API 调用的身份验证,属敏感数据,须妥善加密存储。\n- **不做** cookie 登录 + 2FA + 导出后台渠道明文 key 的管理员迁移路径(all-api-hub 的 newApiSession 能力),本设计不涉及。\n- new-api 归入 `OriginType=relay`、`OriginTier=second`(中转站),信任等级不因自动化提升;实时质量仍由运行画像决定。\n- 认证令牌像其他订阅凭据一样由用户手动录入,CCX 不代替用户在 new-api 侧注册或登录。\n\n**认证与凭据模型**:\n\nnew-api family 的管理接口鉴权(依据 all-api-hub 传输层实现):\n\n```text\nAuthorization: Bearer # 系统访问令牌;raw 模式可去掉 Bearer 前缀\nNew-API-User: # 当前用户 ID(必带)\nUser-id: # fork 兼容回退(Veloera/voapi/Super-API 等)\nContent-Type: application/json\n```\n\n统一响应信封:`{ \"success\": bool, \"data\": T, \"message\": string }`;解析时先判 `success`,再取 `data`。\n\n**SubscriptionProfile 扩展**:\n\n在 §3.2.2 的 `SubscriptionProfile` 基础上,为可自动接入的订阅新增 provider 集成字段(沿用「默认关闭 + 显式 opt-in」守则):\n\n```go\n// SubscriptionProfile 新增字段(复用 Phase 4 Item 6 的 SubscriptionAutoRefresh 开关)\ntype SubscriptionIntegration struct {\n Kind string `json:\"kind\"` // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | ...\n BaseURL string `json:\"baseUrl\"` // new-api 站点地址(同时作为新建渠道的上游 baseURL)\n AccessToken string `json:\"-\"` // 系统访问令牌;加密存储,不出 API 响应\n UserID string `json:\"userId\"` // New-API-User 头所需\n AuthTokenMode string `json:\"authTokenMode,omitempty\"` // \"bearer\"(默认) | \"raw\"\n // 自动建 key 的模板\n ProvisionKeyName string `json:\"provisionKeyName,omitempty\"` // 默认 \"ccx-autopilot\"\n ProvisionGroup string `json:\"provisionGroup,omitempty\"` // 建 key 时指定分组,空=默认分组\n ProvisionModels []string `json:\"provisionModels,omitempty\"` // model_limits,空=不限制\n // 自动同步产物(回填,只读展示)\n ProvisionedTokenID int `json:\"provisionedTokenId,omitempty\"`\n LinkedChannelUID string `json:\"linkedChannelUid,omitempty\"`\n}\n```\n\n`AccessToken` 必须与现有敏感字段一样走加密存储,序列化到前端时脱敏(仅显示尾部若干位)。\n\n**new-api 接口清单**(本集成实际调用的子集,路径以主线 new-api 为准,字段名保持与上游一致):\n\n| 用途 | 方法 | 路径 | 请求体 | 响应关键字段 |\n|------|------|------|--------|--------------|\n| 校验令牌 + 查用户/余额 | GET | `/api/user/self` | — | `data.id`、`data.username`、`data.quota`(剩余额度)、`data.used_quota` |\n| 拉分组倍率 | GET | `/api/user/self/groups` | — | `data: { : { desc, ratio } }` |\n| 站点全部分组 | GET | `/api/group` | — | `data: string[]` |\n| 账号可用模型 | GET | `/api/user/models` | — | `data: string[]` |\n| 创建代理 key | POST | `/api/token/` | `CreateTokenRequest`(见下) | `data.key` / `success` |\n| key 列表 | GET | `/api/token/?p={p}&size={n}` | — | `data.items[]`(含 `id/key/name/status/remain_quota`) |\n| 更新 key | PUT | `/api/token/` | `{ ...token, id }` | `success` |\n| 删除 key | DELETE | `/api/token/{id}` | — | `success` |\n\n`CreateTokenRequest`:\n\n```json\n{\n \"name\": \"ccx-autopilot\",\n \"remain_quota\": 0,\n \"expired_time\": -1,\n \"unlimited_quota\": true,\n \"model_limits_enabled\": false,\n \"model_limits\": \"\",\n \"allow_ips\": \"\",\n \"group\": \"\"\n}\n```\n\n`quota` 是 new-api 内部额度单位(非美元),换算比例读 `GET /api/status` 的 `price` / `PaymentUSDRate`(无鉴权),或允许用户在订阅中手填换算率;MVP 直接以 quota 原值展示 + 站点换算率估算余额。\n\n**接入流程**(订阅中心「新增订阅 → 类型选 new-api」):\n\n```text\n1. 用户输入:站点 baseURL + 系统访问令牌(access_token) + userId\n (userId 可选自动获取:先用令牌调 /api/user/self 拿 data.id 回填)\n2. 校验:GET /api/user/self\n ├─ success=false / 401 → 令牌无效或 userId 不匹配,报错停止\n └─ 成功 → 记录 username、quota,创建 SubscriptionProfile(provider=new_api, originTier=second)\n3. 拉分组:GET /api/user/self/groups → 写入 GroupMultipliers({group: ratio})\n GET /api/user/models → 记录账号可用模型,供渠道 supportedModels 参考\n4. 建 key:POST /api/token/(按 ProvisionKeyName/Group/Models 模板)\n ├─ 已存在同名 ccx-autopilot key → 复用其 id,不重复创建(列表比对 name)\n └─ 新建成功 → 回填 ProvisionedTokenID,取 data.key 作为渠道 apiKey\n5. 自动建渠道:用 baseURL + 新建 key 创建一个上游渠道\n ├─ channelKind 由用户选择或按站点探测(messages/chat/responses/gemini)\n ├─ status=unknown,autoManaged=true,OriginType=relay\n ├─ 建立 SubscriptionUID → ChannelUID 链接(回填 LinkedChannelUID)\n └─ 触发 §8.4 的 Discovery + 能力测试标准流程\n6. 完成:订阅卡片显示余额、分组倍率、已绑定渠道、探测进度\n```\n\n关键点:new-api 集成**不额外造探测逻辑**,第 5 步之后完全复用现有 §8.4「添加并探测」链路;集成层只负责「令牌 → key + 分组 + 余额 + baseURL」的自动填充,把手填 baseURL/key 的动作自动化掉。\n\n**刷新与同步**(复用 `POST /api/subscriptions/{uid}/refresh`):\n\n| 触发 | 动作 |\n|------|------|\n| 手动刷新余额 | `GET /api/user/self` 更新 `Balance` + `BalanceUpdatedAt` |\n| 定时刷新(可选,opt-in) | 复用 Phase 4 Item 6 `SubscriptionAutoRefresh`;new-api 作为 `relay` provider adapter 纳入,默认关闭 |\n| 分组倍率变更 | 刷新时对比 `/api/user/self/groups`,`GroupMultipliers` 变化 → 更新并提示「套餐倍率已变」 |\n| key 失效检测 | 渠道侧探测到该 key 全 401 → 订阅卡片提示「key 可能已删除/禁用」,提供「重新建 key」一键动作(重跑第 4-5 步,复用旧渠道,只换 apiKey) |\n| 可用模型变更 | `/api/user/models` 结果哈希变化 → 触发对应渠道重新 Discovery |\n\n**安全与边界**:\n- `access_token` 权限等同 new-api 账户,须加密存储、脱敏展示、禁止写入日志;刷新失败连续 N 次自动停用该订阅的自动刷新,只保留手动。\n- 自动建 key 默认 `unlimited_quota=true` 但可由用户改为限额模板;建 key 是有副作用的写操作,须在接入前明确告知用户「将在 new-api 侧创建一个名为 ccx-autopilot 的令牌」。\n- new-api 是 `OriginTier=second`,**不得**用作隐私敏感的 routing advisor / classifier(遵守 §3.2.1 约束)。\n- fork 兼容:请求同时带 `New-API-User` 与 `User-id` 头;响应信封字段名(`success/data/message`)在部分 fork 可能不同,adapter 需容错解析,失败时回退手动录入。\n- 一个 new-api 订阅默认只自动建/绑定一个渠道;用户如需该站多协议渠道,可在同订阅下手动增建,共用同一 key。\n\n### 8.5.2 订阅 Provider Adapter 框架(多来源余额与接入)\n\n§8.5.1 的 new-api 集成不是特例,而是「订阅 Provider Adapter」框架的一个实现。Phase 4 Item 6 已落地 openai/anthropic/google 的余额刷新 adapter,本框架把它抽象成统一接口,让 new-api 及后续来源(GitHub Copilot、Codex、Nvidia、MiniMax、DeepSeek 等)按相同契约接入,避免每加一个来源就散落一套逻辑(DRY)。\n\n**统一接口**:\n\n```go\n// backend-go/internal/autopilot/subscription/adapter.go\ntype ProviderAdapter interface {\n Kind() string // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | \"copilot\" | \"deepseek\" | ...\n\n // 校验凭据,返回账号身份(用户名/账户标识);失败即凭据无效\n Verify(ctx context.Context, cred Credential) (AccountInfo, error)\n // 查余额/额度;不支持返回 ErrUnsupported,由 UI 降级为手动录入\n FetchBalance(ctx context.Context, cred Credential) (Balance, error)\n // 拉分组倍率;仅 new-api 类中转有,官方直连返回 ErrUnsupported\n FetchGroups(ctx context.Context, cred Credential) (map[string]GroupRatio, error)\n // 自动创建代理 key;官方直连通常不支持(key 即用户 API key),返回 ErrUnsupported\n ProvisionKey(ctx context.Context, cred Credential, tmpl KeyTemplate) (ProvisionedKey, error)\n // 可用模型列表;用于渠道 supportedModels 参考\n FetchModels(ctx context.Context, cred Credential) ([]string, error)\n}\n```\n\n每个能力独立、可选:adapter 只实现自己支持的部分,不支持的统一返回 `ErrUnsupported`(接口隔离原则),UI 据此隐藏/降级对应入口。所有 adapter 共用 `SubscriptionAutoRefresh` 的 opt-in 开关与 `Validate()` 兜底,与 Phase 4 现有防御模式一致。\n\n**能力矩阵**(✅ 支持 / ⚠️ 部分或需确认 / ❌ 不支持,走手动):\n\n| 来源 | Kind | 验证 | 查余额 | 拉分组 | 自动建 key | OriginTier |\n|------|------|:----:|:------:|:------:|:----------:|------------|\n| new-api family | `new_api` | ✅ | ✅ quota | ✅ | ✅ | second |\n| OpenAI 官方 | `openai` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Anthropic 官方 | `anthropic` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Google 官方 | `google` | ✅ | ⚠️ | ❌ | ❌ | first |\n| GitHub Copilot | `copilot` | ⚠️ token | ❌ 无额度概念 | ❌ | ❌ | first |\n| Codex plan | `codex` | ⚠️ | ⚠️ 用量窗口 | ❌ | ❌ | first |\n| Nvidia NIM | `nvidia` | ✅ | ⚠️ credits | ❌ | ❌ | first |\n| MiniMax | `minimax` | ✅ | ⚠️ | ❌ | ❌ | first |\n| DeepSeek | `deepseek` | ✅ | ✅ balance API | ❌ | ❌ | first |\n\n说明:官方直连来源普遍**不支持自动建 key**(用户的 API key 本身就是凭据,直接作为渠道 apiKey 录入即可)、也**没有分组倍率**(价格按官方定价表)。它们的 adapter 价值集中在「验证 + 查余额/用量窗口」,把 §3.2.4 UsageWindow 的官方用量 API 来源坐实。DeepSeek 有独立余额查询端点,可完整支持余额。Copilot 属订阅制、无 token 余额概念,只做验证 + 用量提示。\n\n**接入分层**(YAGNI:不强求一次做全,按价值排期):\n\n```text\nP0(本轮):new_api(完整:验证+余额+分组+建key+自动建渠道)\n —— 覆盖用户最主要的中转站场景\nP1:deepseek(验证+余额)、官方三家余额已在 Phase 4 完成 → 补 UsageWindow 对齐\nP2:nvidia / minimax(验证+credits/余额)\nP3:copilot / codex(验证+用量窗口提示,无余额)\n```\n\n每个来源的具体端点在实现时于 `adapter.go` 各 provider 文件内落地,本设计只固定接口契约与能力矩阵,避免文档与上游 API 变动强耦合。新增来源 = 实现 `ProviderAdapter` + 在能力矩阵登记,不改框架。\n\n**能力缺失与降级**:\n- adapter 任一能力返回 `ErrUnsupported` → 订阅中心对应字段回退为手动录入(余额手填、倍率手填),不报错、不阻塞接入。\n- adapter 验证失败/超时 → 订阅可仍以「手动模式」创建,仅失去自动刷新与自动建 key,其余流程(手填 baseURL+key 建渠道)不受影响。\n- 官方来源无「自动建 key」时,接入流程退化为 §8.4 标准快速添加:用户填 API key,adapter 只补验证与余额。\n- 所有自动刷新遵守 `SubscriptionAutoRefresh` 默认关闭;连续失败自动停用自动刷新并在驾驶舱「人工待办」提示。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3,\n \"providerTimePricing\": {\n \"deepseek\": {\n \"effectiveFrom\": \"2026-07-20T00:00:00+08:00\",\n \"timeZone\": \"Asia/Shanghai\",\n \"peakMultiplier\": 2,\n \"peakWindows\": [\n { \"start\": \"09:00\", \"end\": \"12:00\" },\n { \"start\": \"14:00\", \"end\": \"18:00\" }\n ]\n }\n }\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n`providerTimePricing.effectiveFrom` 使用 RFC3339;修改该值即可调整激活时间,设为空字符串则停用对应规则。\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**Phase 3A 状态(2026-07-09)**:低风险、不改真实路由语义的子集已完成并接入真实链路——`Manager.collectAll()` 新旧画像对比产出变更事件(`DetectProfileChanges`)、`ProfileChangelogStore`(环形内存 + SQLite,30 天机会性清理)、`EventHub` 内存 pub/sub、`GET /api/health-center/changelog`(历史)+ `GET /api/health-center/events`(WebSocket 实时推送)、`AutoDiscoveryRunner` 发布 `discovery_completed`/`auto_mapping_applied`、前端 `ProfileChangelogTimeline.vue` 接入健康中心页并支持断线自动重连。均为只读展示,未修改 `SmartRouter`/`EndpointAttemptPolicy` 调度逻辑。落地时顺带发现并修复一个真实缺口:浏览器原生 WebSocket 无法设自定义 header,为此给 `middleware.getAPIKey` 增加了 `Sec-WebSocket-Protocol` 鉴权回退(业界标准做法,仅新增一个 key 来源,不降低现有校验)。\n\n**Phase 3B(已全部完成,2026-07-09)**:模型自动映射、自动恢复探测、晋升/降级机制会直接改变真实调度决策,风险明显高于 3A,四个子项已分批全部完成。§12.2 P1.5 的 SQLite schema version / 版本化 migration / `OriginType`&`OriginTier` 旧配置 backfill / `killSwitch`+按 task class/channel disable **已于 2026-07-09 补强完成**(详见 §12.2 P1.5)。**Phase 3B-1(自动恢复探测补全)已于 2026-07-09 完成**:修复了 L2 探测子系统里此前从未打通的两个真实 bug——① `collectAll()` 每轮把新构造的画像整行 Upsert,会把 `ProbeWorker` 刚写入的 `LastProbeAt`/`ProbeSuccess`/`ConsecutiveProbeSuccess` 等字段清零,导致 6 小时探测冷却期形同虚设(修复:`carryForwardProbeFields` 在 Upsert 前搬运 Probe* 字段,不影响 L1 诊断字段的现有覆盖逻辑);② `extractRawAPIKey` 是返回 `KeyMask` 掩码值的占位 stub,探测请求实际带的是假 key(修复:新增 `APIKeyResolver` 回调 + `Manager.ResolveAPIKey`,通过遍历配置里对应渠道的 `APIKeys` 逐个计算 `KeyHashFromAPIKey` 比对来还原明文;resolver 未注入或未命中时 fail-open 跳过探测且不消耗预算)。同时补齐了 `degraded/limited → healthy` 的连续成功恢复路径(新增 `KeyEndpointProfile.ConsecutiveProbeSuccess` 计数器 + `HealthCheckConfig.ProbeRecoveryThreshold`,默认连续 2 次探测成功才恢复,避免单次探测噪声导致状态抖动;探测失败计数器清零)。**Phase 3B-3(StabilityTier 晋降级滞后)已于 2026-07-09 完成**(提交 664d3fff):在原本每轮从瞬时 1h 窗口重算的无记忆 `StabilityTier` 上加一层连续窗口滞后——新增 `KeyEndpointProfile.EffectiveStabilityTier`/`StabilityPendingTier`/`StabilityPendingStreak` 三字段 + 纯函数 `applyStabilityHysteresis`(对称阈值:连续 N 轮同值才切换生效档,`HealthCheckConfig.StabilityHysteresisWindows` 默认 2;首次画像直接采用,不等待阈值,避免新渠道卡在零值),`AggregateChannelProfile` 改读 `EffectiveStabilityTier`(空则回退 `StabilityTier`)。晋升/降级机制的数据模型仅限于 StabilityTier,不改变 QualityTier/SpeedTier/CostTier 语义。**Phase 3B-2(模型自动映射闭环)已于 2026-07-09 完成**(提交 0f02e988,随后修复一处 critical 集成 bug,提交 856372c4):新建 `ModelProfileStore`(SQLite,`model_profiles` 表)+ 扩展 `AutoDiscoveryRunner` 为每个发现模型写入 `ModelProfile`(仅 `AutoManaged` 渠道);新建 `ModelResolver` 实现 §5.4 五步逻辑(手动 `RedirectModel` 短路 → `GetModelProfiles` → `filterByCapabilityFloor` → `rankBySimilarity`);`scheduler` 包新增 `ModelSupportResolverFunc` hook(fail-open,未注入或返回不支持时严格回退到原 `ExplainModelSupport`);`endpoint_policy.go` 的 `resolveMappedModel`(此前是永不触发的死代码——`profile.ModelMapping` 从未被写入)现调用 `ModelResolver`;`handlers/common` 在 `RedirectModel` 之后应用 `MappedModel` 覆盖请求体,并按 `EchoMappedModel` 配置回显 `X-CCX-Mapped-Model`/`X-CCX-Original-Model`/`X-CCX-Mapping-Source` 响应头。三条件安全门控:`ModelMapping.AutoResolve==true`(**默认由死配置的 true 改为 false**)AND `RoutingMode ∈ {assist, auto}` AND 无 `KillSwitch`,三者任一不满足则所有新增代码路径行为与今天字节级一致;且仅 `AutoManaged` 渠道触发,手动配置渠道完全不受影响。对抗性复核发现并修复一个真实 critical bug:`scoreEndpointForKey` 用空 `channelUID` 计算 `EndpointUID` 并写入 `modelByUID`,而 `handlers` 层用真实 `channelUID` 查 `modelByUID`,两者哈希永不相等导致 `MappedModel` 在 key 级调度路径上完全查不到(提交 856372c4 修复:命中画像后改用 `profile.EndpointUID`,新增回归测试锁定该不变量)。至此 Phase 3B 四个子项(自动恢复探测、StabilityTier 滞后、模型自动映射、active filter 前置判定)全部完成;SLO regression 自动回滚仍是独立子系统,留待后续单独立项。\n\n**范围**:\n- [x] 运行时指标驱动画像实时更新(Phase 3A:`collectAll()` 每轮 diff + changelog + WebSocket 推送)\n- [x] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)—— Phase 3B-2(2026-07-09 完成,提交 0f02e988 + 856372c4)\n- [x] active model filter / context filter 支持自动映射前置判定 —— Phase 3B-2:`scheduler.ModelSupportResolverFunc` fail-open 接入 `select.go`\n- [x] 自动恢复探测(limited/dead → healthy)—— Phase 3B-1(2026-07-09 完成):`APIKeyResolver` + `carryForwardProbeFields` + `ConsecutiveProbeSuccess`/`ProbeRecoveryThreshold`\n- [x] 晋升/降级机制(连续成功→升级,连续失败→降级)—— Phase 3B-3(2026-07-09 完成,提交 664d3fff):`StabilityTier` 连续窗口滞后(`EffectiveStabilityTier`),暂不含 QualityTier/SpeedTier/CostTier\n- [x] WebSocket 推送画像变更事件(Phase 3A:`GET /api/health-center/events`)\n- [x] 前端画像变更历史/时间线(Phase 3A:`ProfileChangelogTimeline.vue`)\n\n**预估工期**:2-3 周(3A、3B-1、3B-2、3B-3 均已完成;advisor 与全局 auto 的 SLO 回滚均已补齐)\n\n> 状态校正(2026-07-17):上方 Phase 3B 历史说明中的“SLO regression 自动回滚待单独立项”已完成;advisor 回滚与全局 auto 上线闸门/自动降级是两个独立保护层。\n\n### Phase 4:高级特性\n\n**Phase 4 状态(2026-07-09,全部完成)**:8 项全部实现并接入真实链路,均遵循\"默认关闭 + 显式 opt-in\"安全守则。\n\n**范围**:\n- [x] 多维度标签系统扩展(用户自定义标签)——Item 1\n- [x] 成本报表:按用户/模型/渠道/key 统计真实有效成本——Item 2(新增 `proxyKeyMask` 身份贯穿链路:`middleware.GetProxyKeyMask` → `ChannelLog`/`RequestRecord` → SQLite `proxy_key_mask` 列,供 Item 4 复用)\n- [x] Trusted advisor SLO regression 自动回滚——Item 3(默认关闭;`SLORollbackConfig`,连续 degrading 窗口数默认 3,不做自动恢复以避免震荡)\n- [x] 渠道推荐:根据使用模式推荐新渠道——Item 4\n- [x] 批量渠道管理(导入/导出/模板)——Item 5\n- [x] 订阅中心 provider adapter:可选自动刷新余额/套餐状态——Item 6(默认关闭;仅 openai/anthropic/google 且显式填写 BillingAPIKey 的订阅生效,中转/公益渠道继续手动维护)\n- [x] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板——Item 7\n- [x] A/B 测试:低比例统计抽样双发——Item 8(默认关闭,默认 1% 抽样 + 每小时 60 次硬预算;主请求路径不受影响,影子请求在主响应返回后异步发起)\n\n**关键实现说明**:\n- Item 3/6/8 均在 `AutopilotRoutingConfig` 新增独立配置块(`SLORollback`/`SubscriptionAutoRefresh`/`ABTest`),`Validate()` 统一做负值/越界兜底,保持与既有字段一致的防御模式。\n- Item 4 直接复用 Item 2 的 `proxyKeyMask` 身份标记,未重新发明身份识别机制。\n- **全局 auto 安全闸门(2026-07-17)**:请求终态按 15 分钟窗口无偏聚合,详细 trace 继续抽样;管理 API 只在 shadow/assist 已积累至少 500 个有效请求、24 小时连续观测且成功率/fallback/fail-open/p95 均达标时允许切换 `auto`。后台连续检测三个已完成 auto 窗口,相对最近七天安全模式基线持续恶化时,持久化降级到 `assist` 并记录 `autopilot_auto_safety_events`,不自动恢复。\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- ~~SQLite schema version 和幂等 migration。~~ **已完成**:`ensureSchemaVersion` 基于 `PRAGMA user_version`,当前 7 张表定版为 v1 基线;库版本高于代码版本时返回 error(fail-closed,不做任何写操作),交给下游 fail-open 分支接管。\n- ~~旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。~~ **已完成**:`ensureOriginBackfill` 只补 `\"unknown\"`,不做任何基于 URL/名称的猜测推断;已接线到 `Profiler.DeriveEndpointProfile`,`BreakTieByOriginTier` 现在能吃到真实数据(此前一直是 fallback 的 `unknown`)。\n- ~~profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。~~ **已确认满足并测试锁定**:`main.go` 现有结构本就是 fail-open(`NewProfileStore`/`NewManager` 出错只打日志、`autopilotManager` 保持 `nil`,下游全部有 `nil` 守卫),本次新增 `schema_migration_test.go` 用测试锁定这条不变量。\n- ~~全局 `killSwitch`、按 task class disable、按 channel disable。~~ **已完成**:新增 `AutopilotRoutingConfig.DisabledTaskClasses`/`DisabledChannelUIDs`;`CandidateFilterFor`/`executeFilter`/`BuildPlan` 三处一致接入,`smart_router_invariant_test.go` 覆盖\"task class 禁用后回退默认调度\"\"channel 禁用后不出现在候选里\"\"dry-run 与真实路径行为一致\"三条不变量。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。**部分完成**:`ReleaseController.EvaluateAndApplyRegression` 已实现三窗口回归检测和 `SafetyOverride` 降级机制;`RoutingModeActive` 已加入配置枚举和 `IsAutopilotActive`。\n\n#### P1.6 Trace v2 契约与灰度发布\n\n> 对应实施计划:`docs/superpowers/plans/2026-07-23-autopilot-trace-rollout.md`\n\n**Trace v2 核心变更:**\n\n- `RoutingDecisionTrace` 增加 `SchemaVersion`、`TraceRevision`、`RequestCorrelationId`、`ReleaseID`、`PolicyFingerprint`、`TargetMode`/`EffectiveMode`/`Cohort`、`SchedulerDecision`、`EndpointAttempts` 字段。\n- 新增 `TraceDetailV2`、`TraceSummary`、`SchedulerDecisionSummary`、`EndpointAttemptSummary` DTO;使用显式 DTO 落盘而非直接 JSON 化运行时结构。\n- `ComparisonStatus` 三态枚举(`matched`/`mismatched`/`uncompared`)替代裸 `bool`。\n- `SanitizeForPersistence` 与 `SanitizeForResponse` 双边界脱敏;`GenerateTraceUIDv2` 使用 `crypto/rand` 碰撞安全生成。\n- `RoutingReleaseSnapshot` 不可变发布快照,入口冻结后续只读。\n\n**SQLite v6 迁移:**\n\n- trace 表新增列:`schema_version`、`trace_revision`、`request_correlation_id`、`release_id`、`policy_fingerprint`、`persistence_class`、`details_json`。\n- 窗口表重建主键:增加 `release_id`/`policy_fingerprint`/`cohort` 维度,新增 `compared_count`/`matched_count`/`mismatch_count`/`uncompared_count` 全量比较计数。\n- safety event 表增加 `release_id`/`policy_fingerprint` 字段。\n\n**灰度发布状态机:**\n\n```text\noff --管理员启用--> shadow --门槛满足--> assist --门槛满足--> auto --100% 稳定--> active\n```\n\n| 状态 | 行为 | 放量规则 |\n|---|---|---|\n| `off` | 不调用 Autopilot | rolloutPercent=0 |\n| `shadow` | 计算+记录,不影响调度 | rolloutPercent=0,安全基线 |\n| `assist` | 重排候选,不删除 | rolloutPercent 1→100 |\n| `auto` | 硬约束过滤+重排 | rolloutPercent 1→100 |\n| `active` | 与 auto 语义相同 | rolloutPercent=100,1% shadow control |\n\n**ReleaseController**(`release_controller.go`):\n\n- 集中处理相邻状态迁移校验(逐级晋升,降级随时允许)。\n- `ComputeCohort` 使用 session ID / request correlation ID 与 `rolloutSeed` 做稳定哈希分桶。\n- `EvaluateAndApplyRegression` 检查三窗口回归并应用 `SafetyOverride`。\n- `AllowedTransition` 禁止跳级,`modeRank` 确定顺序。\n\n**只读 Trace API:**\n\n| 接口 | 返回 | 约束 |\n|---|---|---|\n| `GET /api/autopilot/traces` | `TraceSummary[]` | 游标分页、release/cohort/mode 过滤、partial 标记 |\n| `GET /api/autopilot/traces/:traceUid` | `TraceDetailV2` | 404=未找到/已过期/未采样;503=存储不可用 |\n| `GET /api/autopilot/traces/stats` | `TraceStatsResponse` | 三态比较统计、Mode/TaskClass 分布 |\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | shadow 样本/准确率/critical misroute 门槛;active 后 SLO regression 自动 rollback;全局 kill switch |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "01e8421801c2c699d13f4ef638cda19f9af22414": {"t": 1785759338.1182804, "n": 166787, "h": "a1cf1ebc69b6d980", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.2.3 同订阅多 Key:能力共享与配额负载均衡\n\n**问题**:官方订阅 plan(Claude Max、Codex plan 等)允许用户持有多个 key,或购买多份相同套餐。若每个 endpoint 独立探测与配置,探测预算被浪费、配置重复;请求量大时还需要在同 plan 的多个 key 之间按官方 RPM 限速自动负载均衡,而不是打满第一个 key 才溢出到下一个。\n\n**能力检测去重(同一套餐只需一套能力检测和配置)**:\n\n```text\nSubscriptionUID 相同的 endpoint 共享一份「订阅级能力画像」:\n 模型列表 / vision / tool / reasoning 支持 / 协议兼容开关 → 只探测一次,\n 存在 SubscriptionProfile.SharedCapability,endpoint 画像以引用方式继承。\n\n仍然 per-endpoint 维护的:健康状态、延迟、熔断、用量窗口\n (key 可能被单独封禁/限速,能力相同不代表运行态相同)\n\n继承失效条件:某 key 实测模型列表哈希与订阅级不一致(GroupChangeDetector 信号)\n → 该 key 脱离继承、独立探测,并在 UI 标记「与套餐能力不一致」警告。\n```\n\n**配额负载均衡(Phase 1 shadow 展示,Phase 2 生效)**:\n\n```text\n每 key 维护配额余量视图:\n remainingRPM = officialRPM − 当前窗口已用\n officialRPM 来源优先级:内置 manifest > 用户配置 > RateLimitDiscoverer 建议\n\n同订阅多 key 时按「剩余余量加权」分配请求(余量大者优先、\n窗口最早重置者兜底),替代顺序打满:\n 仅当渠道 autoManaged 且同一 SubscriptionUID 下 key 数 > 1 时启用;\n 否则保持现有 sequential/random key 顺序策略不变。\n\n某 key 的窗口配额耗尽 → 临时降权并切到同订阅其他 key,\n不熔断整个渠道(这是配额耗尽,不是故障)。\n```\n\n### 3.2.4 订阅用量窗口 (Usage Windows)\n\n部分官方 plan 提供 5h / 日 / 周 / 月等窗口的用量查询与限制(如 Claude 订阅的 5h 滚动窗口 + 周配额)。在 key 的位置展示这些窗口,帮助用户掌握使用情况:\n\n```go\ntype UsageWindow struct {\n Window string `json:\"window\"` // \"5h\" | \"day\" | \"week\" | \"month\"\n Used float64 `json:\"used\"` // 已用量\n Limit float64 `json:\"limit\"` // 上限,0 = 未知\n Unit string `json:\"unit\"` // requests | tokens | credits | percent\n ResetsAt time.Time `json:\"resetsAt\"` // 窗口重置时间\n Source string `json:\"source\"` // official_api | response_header | local_metering\n FetchedAt time.Time `json:\"fetchedAt\"`\n}\n// KeyEndpointProfile 与 SubscriptionProfile 均增加 UsageWindows []UsageWindow\n```\n\n数据来源优先级:\n\n```text\n1. 官方用量 API:有 provider adapter 的 plan 主动拉取(§Phase 4 订阅 adapter 的可提前子集)\n2. 响应头:anthropic-ratelimit-* / x-ratelimit-* 等(复用 RateLimitDiscoverer 的信号 tap)\n3. 本地计量兜底:CCX 按 key 累计请求数/token(误差来自窗口起点对齐,标注 Source=local_metering)\n```\n\nUI 要求:key 行内显示主窗口(5h)用量进度条,悬浮展开全部窗口;用量 >80% 黄色预警、超限红色并显示预计重置时间。调度联动(Phase 2):窗口余量进入配额负载均衡权重,窗口耗尽的 key 临时降权而非熔断。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n#### 4.2.1 内置模型清单 (Builtin Models Manifest)\n\n**问题**:部分官方订阅 plan 的入口没有正确的 models 接口——要么不存在(404)、要么需要额外权限、要么返回与订阅实际可用模型不符的列表(典型:Claude 订阅 OAuth 入口、Codex plan 入口、部分官方企业网关)。对这些 baseURL 做模型发现会失败或产出错误画像。\n\n**方案**:CCX 内置「已知官方 baseURL → 模型清单」映射表,命中时直接返回内置内容,完全跳过上游 models 接口:\n\n```go\n// backend-go/internal/config/builtin_models_manifest.go(生成式,随模型注册表更新)\ntype BuiltinModelsManifest struct {\n BaseURLPattern string `json:\"baseUrlPattern\"` // 精确 host 或前缀匹配,如 \"api.anthropic.com\"\n ServiceType string `json:\"serviceType\"` // messages/responses/…\n PlanHint string `json:\"planHint,omitempty\"` // 订阅类型提示(如 \"claude_subscription\")\n ModelIDs []string `json:\"modelIds\"` // 该入口实际可用的模型清单\n DisableProbe bool `json:\"disableProbe\"` // true 时 Discovery 跳过 GET /v1/models\n}\n```\n\n**两个生效位置**:\n\n```text\n1. Discovery(§4.2 流程第 2 步之前):\n baseURL 命中 manifest 且 DisableProbe=true\n → 跳过 GET /v1/models,直接用 ModelIDs 生成 ModelProfile[]\n → ProfileSource = \"builtin_manifest\",不再因 models 接口失败而误判 misconfigured\n\n2. /v1/models 代理响应(现有 Models API):\n 客户端向 CCX 请求 /v1/models 时,聚合结果中该渠道的贡献\n 直接取内置清单,不向上游发起 models 请求\n (渠道显式配置了 supportedModels 时仍以用户配置优先)\n```\n\n**维护与覆盖顺序**:\n\n```text\n渠道显式 supportedModels(用户手动) > 内置 manifest > 上游 models 接口实探 > 内置候选模型回退\n```\n\nmanifest 与模型注册表同源生成(`generated_model_registry.go` 同级),新模型发布时随注册表一起更新;HealthAnalyzer 对命中 manifest 的 endpoint 不将「models 接口 404/403」计入 misconfigured 信号。\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n MappedEffort EffortLevel // 自动映射的 effort 级别(空=未决定)\n MappedEffortDecided bool // effort 是否由 Autopilot 决定\n EndpointUID string // 稳定 endpoint ID\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n\n // ResolvedModelByEndpointUID(旧 API):仅返回模型名,向后兼容。\n // ResolvedTargetByEndpointUID(新 API):返回 model + effort 原子决策。\n // 两者从同一份 targetByUID 派生,避免两个真值源。\n ResolvedModelByEndpointUID func(endpointUID string) string\n ResolvedTargetByEndpointUID func(endpointUID string) *ResolvedRouteTarget\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20;assist 下 dead/misconfigured 额外 -100 并保留在队尾\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法细分时进入 `general`;有规范 Knowledge 基准则使用其软评分,缺少直接证据时仍取 0.5 中性值。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n**手动 L3 第一版状态(2026-07-15)**:已实现 `pq-v1-20260715` 固定 JSON canary,覆盖\nMessages、Chat Completions、Responses 与 Gemini 上游协议。调用方只能指定 `endpointUid`、\n`modelId` 和 1~3 次重复数,不能注入 prompt。评分仍按 40% 完整性、30% 语义正确性、\n15% 严格 JSON 格式、15% 延迟执行;第一版使用非流式完整响应延迟作为首 token 延迟代理,\n后续引入流式采样时再替换该维度。单次有效样本 confidence=0.6,最多三次提升到 0.8;\n失败样本按 0 分计入均值,全部失败不写画像。\n\n安全边界:仅管理 API 手动触发,不启动自动 worker;当前进程按 UTC 日设置 12 次硬预算,\n请求前原子预留,单次 90 秒超时、响应体上限 64 KiB。同一 endpoint×model 不并发执行;\n单次请求最多允许 2048 个输出 token,以容纳推理模型的隐藏思考并限制最坏额度消耗。\n对声明可控推理的模型固定采用最低可用 effort;Claude 兼容端点若只暴露\n`reasoning_content` 而未声明 thinking 模式,则与 capability-test 一致显式关闭 thinking。\n原始模型输出和明文 Key 既不落盘也不返回,只保存分数、canary 版本、置信度和脱敏判定。\n`user_feedback` 仍高于 `probe`,自动发现重建 ModelProfile 时必须保留既有质量证据。\n跨进程共享预算与 LLM-as-judge/语义向量评分仍属于后续阶段。\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设 + 自定义\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n| `custom` | 用户自定义 | 用户自定义 | 高级用户微调,范围 0.0 ~ 3.0 |\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法细分的通用任务;缺少基准证据时中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general;有规范 Knowledge 基准时使用其软评分,缺少直接证据时取 0.5 中性。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? canonicalBenchmark[model][domain] * providerQualityFactor\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 规范模型基准 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n#### 5.7.4 规范模型能力基准\n\n`shared/model-registry/ccx_model_registry.json` 的 `benchmarkProfiles` 独立保存规范模型能力上界,不与 `upstreamCapabilities` 混合。后者仍是 context、vision、tool calls 等硬能力的事实源;benchmark 只参与 Autopilot 软评分和 trace 解释,绝不改变 `supportedModels` 或 CapabilityFloor。\n\n```text\nEffectiveCapability[domain]\n = CanonicalCeiling[model, domain]\n × ProviderQualityFactor[endpoint]\n\nProviderQualityFactor = 1 - confidence × (1 - providerQualityScore)\n无渠道质量证据或 confidence<0.5 时 factor=1;可信证据只能从规范模型上界向下折算。\n```\n\n基准数据必须保留领域向量、来源、核验日期、lane、共享结果数和可比类别覆盖率,不能只保存站点 Overall。当前 BenchLM 接入为 `provisional`:Claude Opus 4.8、GPT-5.6 Terra、GPT-5.6 Sol 各有 30 个共享结果,8 类中 5 类可比,核验日期为 2026-07-13。\n\n领域映射:Knowledge → `general`,Math → `reasoning`,Coding → `coding` / `code_review`,Agentic → `agentic`,Multimodal → `aesthetics_ui`(仅弱代理,解释置信度减半)。Writing / Translation 没有直接类别证据时继续回退家族种子或 0.5。BenchLM 的 cohort 归一化、缺失项插补和外部校准决定了 Overall 不是绝对能力值,因此路由只消费可映射的原始类别分。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n#### 5.8.4 联合决策的现状实现(model + effort 原子化)\n\n> 状态说明(2026-07-26):本节描述的联合决策链路已实现并接入真实请求路径(关联计划 [2026-07-26-autopilot-model-effort-joint-routing.md](../superpowers/plans/2026-07-26-autopilot-model-effort-joint-routing.md) 的 Task 1~7)。**渠道级手工 `modelMapping` / `reasoningMapping` 表目前仍然存在且仍然生效**,退役是该计划的 Task 8,尚未执行;手工表当前的优先级关系见下文。\n\n**决策对象。** 自动路由的返回值不再是裸模型名,而是 `autopilot.ResolvedRouteTarget`(`backend-go/internal/autopilot/route_target.go`):\n\n```go\ntype ResolvedRouteTarget struct {\n Model string // 目标模型 ID\n Effort EffortLevel // 目标思考档位;空串表示不改写\n EffortDecided bool // true 表示 effort 由 Autopilot 决定;false 为 passthrough\n Reason string // 决策原因,用于 Trace 与日志\n}\n```\n\n`EffortDecided=false` 且 `Effort` 为空是合法的 fail-open 结果:模型可能被改写,但思考参数保持原样。\n\n**决策发生的位置。** `autopilot/endpoint_policy.go` 的 `resolveMappedModel()` 是统一入口,内部分两路:`resolveManualMapping()` 处理渠道级显式 `ModelMapping`(仍是当前优先级更高的一路,返回的 `ResolvedRouteTarget.EffortDecided` 恒为 `false`,即手工映射只改模型不改档位);`resolveAutoModel()` 调用 `ModelResolver` 做自动模型解析,并在 `model_resolver.go` 的 `rankEligibleModels()` 中按 `SupportedEffortLevels` × `PerTaskClass` 展开 `(model, effort)` 候选、应用 `EffortQualityBonus` 与 anti-effort-inflation 反膨胀 tiebreak(同质量档优先选低 effort)、并按 `EffortFloor` 过滤掉低于任务下界的候选。产出的 `ResolvedRouteTarget` 经 `buildResolvedTargetLookup()` 挂到 `EndpointAttemptPolicy.ResolvedTargetByEndpointUID`,与旧的 `ResolvedModelByEndpointUID`(仅模型,向后兼容)并列。\n\n**决策如何到达上游请求。** `handlers/common/upstream_failover.go` 的 `TryUpstreamWithAllKeys` 在为每个 endpoint 尝试构建请求前,通过 `ResolvedTargetByEndpointUID` 取得 `*ResolvedRouteTarget`,交给 `atomicModelEffortRewrite()` 做一次原子改写:先用 `sjson` 改写 `model` 字段;若 `EffortDecided=true` 且 `Effort` 非空,再检查目标模型的 `ThinkingMode`——`adaptive_only` 的模型跳过思考参数注入(模型自身决定思考深度,守卫逻辑对齐 `providers/claude.go` 的 `applyClaudeReasoningEffort`),否则调用 `config.ApplyReasoningParamStyle()` 写入。模型改写与档位改写共用一次函数调用,任一步 `sjson`/`json` 处理失败即整体放弃、保持原始 body,不会出现\"模型改了、档位没改\"的中间态。\n\n**各协议的注入形态。** `config.ApplyReasoningParamStyle(req, style, effort)`(`config/config_utils.go`)按 `upstream.ReasoningParamStyle` 三态分支:\n\n```text\nstyle=\"thinking\" → Claude 派系:写 thinking.type=enabled + thinking.effort=;\n effort 为 off/none 时写 thinking.type=disabled;同时清理互斥字段\n reasoning / reasoning_effort / thinking.budget_tokens\nstyle=\"reasoning_effort\" → OpenAI Chat 派系:写顶层 reasoning_effort 字段,清理 reasoning\nstyle=\"\" / 默认 → Responses 派系:写 reasoning.effort 对象形态\n```\n\n**Gemini 派系是尚未接通的缺口。** 请求侧提取(`extractGeminiEffortExplicit()`,`handlers/common/reasoning_log.go`)已经能读取 `generationConfig.thinkingConfig.thinkingLevel` 并识别显式 `none`(`thinkingBudget=0`),这条读路径用于日志与 `RequestProfile.ClientEffort` 提取。但写路径——把联合决策的 `target.Effort` 写回 Gemini 请求体的 `thinkingConfig.thinkingLevel`——目前代码中不存在:`atomicModelEffortRewrite()` 唯一的写出口是 `ApplyReasoningParamStyle()`,它只覆盖 Claude/Chat/Responses 三种形态,没有 Gemini 分支;`handlers/gemini/handler.go` 的 `buildProviderRequest()` 也没有调用任何 effort 注入函数。也就是说,Gemini 渠道当前仍会经过 endpoint 级模型改写(`atomicModelEffortRewrite` 会改写 `model` 字段本身),但联合决策选中的思考档位不会落到 Gemini 请求体上,效果等同于 fail-open passthrough。此项在原计划里归属 Task 5 的\"Gemini 派系注入路径\",尚未实现。\n\n**可观测性字段。** 原子改写点会向 `gin.Context` 写入两个字段供 ChannelLog 使用:`effortDecisionSource`(`autopilot` 表示由联合决策产出,`passthrough` 表示未改写或手工映射路径)、`effortClampedByClient`(当客户端显式声明的档位低于 Autopilot 选择的档位时置 `true`,由 `isEffortClampedByClient()` 基于 `EffortLevelOrdinal` 比较得出)。\n\n**手工映射表的现状。** `resolveMappedModel()` 当前的分支顺序是\"显式 `ModelMapping` 优先,自动决策兜底\"——与原计划 Task 7 描述的\"反转优先级\"目标相反的方向仍是默认值;反转开关已作为可配置项落地(提交 `d15e776e`),但默认状态未切换,`ModelMapping` / `ReasoningMapping` 两张表本身尚未删除,`config.ResolveReasoningEffort()`(`config/config_utils.go`)与 provider 层(如 `providers/claude.go`)仍会读取 `ReasoningMapping` 独立生效。表的整体退役是 Task 8 的范围,未开始。\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**请求意图边界(ModelRoutingPolicy)**\n\n跨模型替代不是所有模型的默认能力,只对白名单客户端入口开放:\n\n```text\nmessages:\n fable / opus / sonnet / haiku 及对应 claude-* 完整模型名 → adaptive\nresponses:\n gpt-5.6* / gpt-5.5 / gpt-5.4* / codex-auto-review → adaptive\n其他协议或模型:\n exact-only,只能在探测到相同规范化模型 ID 的 endpoint 之间调度\n```\n\n`adaptive` 允许在满足 Capability Floor 后选择 GLM、MiMo 等其他模型族;`exact-only` 禁止跨模型替代。用户显式配置的 `modelMapping` 仍优先于该策略。调度预览和 endpoint 实际解析必须调用同一分类函数,避免诊断结果与真实请求分叉。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ModelProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选优\n // 上下文窗口只负责准入,不对更大的窗口扣分。\n // 排序优先级:\n // a. 模型质量档越高越优先\n // b. provider 已确认的同档模型能力顺序\n // c. 带置信度折算的供应商实测质量\n // d. 探测延迟\n // e. 同渠道/provider 的模型相对消耗倍率\n // 倍率相同或缺失时再比较公开模型成本\n // f. 同模型族与 model ID 仅作为确定性兜底\n best := rankEligibleModels(eligible, requestModel)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n同渠道套餐倍率与公开 USD/Token 价格是不同单位,禁止直接混算。模型解析器先比较 provider\n提供的相对消耗倍率;只有双方倍率相同,或双方均无倍率时,才回退到统一输入/输出规模下的\n公开价格。优云智算当前内置倍率如下(数字越小越省,`glm-5.2` 的 2 次为限时值):\n\n| 模型 | 每次调用套餐消耗 |\n|------|-----------------:|\n| `glm-5.2` | 2 |\n| `glm-5.1` | 6 |\n| `MiniMax-M2.7` | 2 |\n| `kimi-k2.6` | 5 |\n| `deepseek-ai/DeepSeek-V3.2` | 1 |\n| `deepseek-v4-flash` | 1 |\n\nprovider 可以额外声明同一质量档内已确认的模型能力顺序,但只有该档全部候选都命中该元数据\n时才比较,避免把未知模型误判为弱模型,也保证排序不依赖候选输入顺序。优云模板当前记录\n`glm-5.1 > kimi-k2.6`,因此前者即使消耗 6 次,也会在后者的 5 次成本证据之前胜出。\n`glm-5.2` 即使消耗 2 次,也仍会在质量档高于 DeepSeek 候选时优先;V3.2 与 V4 Flash\n同为 1 次时,再由公开价格及最终确定性规则区分。静态 `modelMapping` 仅保留为用户显式覆盖,\n不承担自动替代策略。\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - autoManaged 的空 supportedModels 不表示支持全部,必须由 endpoint ModelProfile 裁决\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n当 ModelResolver 已正常初始化且路由处于 `assist/auto + autoResolve=true` 时,autoManaged 渠道的画像拒绝是权威结果,调度器不得再 fail-open 到空 `supportedModels` 的兼容语义。Resolver 不可用、开关关闭或 `off/shadow` 时仍保留原有 fail-open,保证急停和降级路径不依赖画像库。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n### 6.9 渠道兼容性统一自学习\n\n渠道级兼容性配置不应要求用户先踩坑、再去翻设置面板找开关。这一节描述如何把这些开关从\n「静态布尔」转为「运行时学习到的能力事实」。\n\n**三态合成**(`config.resolveCompatSwitch`)\n\n```text\n最终生效值 = 用户显式设置(*bool 非 nil) ?? 学习结论 ?? 静态默认\n```\n\n裸 `bool` 无法区分「用户显式关闭」与「用户没设置」,因此六个兼容性字段\n(`NormalizeNonstandardChatRoles`、`StripImageGenerationTool`、`CodexNativeToolPassthrough`、\n`StripEmptyTextBlocks`、`PassbackReasoningContent`、`PassbackThinkingBlocks`)均迁移为 `*bool`。\n用户配置永远最高优先,既是强制覆盖也是逃生阀。\n\n注意:`RuntimeUpstreamForAutoManagedProvider` 清理自动托管渠道的手工配置时必须置 `nil` 而非\n`false`,否则会被当作「用户显式关闭」,使自动托管渠道永远学不会兼容改写。\n\n**记忆载体**:`config.ChannelCompatCache`,落盘 `.config/channel_compat.json`,\n键 `channelUID:keyHash:model`,TTL 24h(与 `DeprecatedParamCache`/`SystemHeaderFilterCache` 一致)。\n`Record` 仅在结论新增或翻转时返回 `true`,调用方据此决定是否重试,防止记忆生效后死循环。\n\n**三条学习路径**(按上游信号强度区分,不可一概而论)\n\n| 信号类型 | 兼容项 | 学习方式 |\n| --- | --- | --- |\n| 硬 400/422 + 可正则匹配 | developer role 降级、Codex 专属工具剥离、历史 thinking 回传、图片工具剥离 | 错误驱动:`CompatTraitFromError` 识别 → 记忆 → 同 Key 立即重试 |\n| 无报错,仅响应形态差异 | `passbackReasoningContent`、`stripEmptyTextBlocks` | 探针驱动:首次遇到该组合时 singleflight 后台探测一次 |\n| 厂商文档已公布约束 | Kimi 固定值参数、`tool_choice`、`thinking` | 前置规避:`ApplyKnownParamConstraints` 发送前直接改写,无需失败一次 |\n\n**分层职责**:`upstream_failover` 负责识别信号、写缓存、编排重试、把结论注入 `upstreamCopy`;\n各 provider 在构造上游请求时读取合成值并执行改写。这样 developer role 降级发生在\nResponses→Chat 转换**之后**的 `reqMap` 上,避开「failover 层拿到的是 Responses `input` 而非\nChat `messages`」这个陷阱。\n\n**防误判约束**(学错并持久化比不学更糟)\n\n- 双条件门控:正则命中之外,还要求请求侧确实携带对应结构(`BodyHasDeveloperRole` 等)\n- 单次重试上限:`Record` 返回 `false` 即不再重试\n- 不跨维度污染:结论不外溢到同渠道其他 Key/模型\n- 仅 400/422 参与学习;429/5xx/超时属容量问题,不是能力问题\n- fail-open:缓存损坏、探测失败一律退化为静态默认,不阻断代理请求\n\n**图片工具归因修正**:图片生成受限时,原实现直接拉黑 (Key,模型)。现改为先学习\n「需剥离图片工具」并重试修复请求,只有剥离后仍失败才回落拉黑——从「惩罚渠道」改为「先修复请求」。\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\nGET /api/health-center/provider-quality/budget → 查看本进程手动 L3 每日预算\nPOST /api/health-center/provider-quality/probe → 对指定 endpoint×model 执行固定质量 canary\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n#### 8.0.1 实现现状与演进方向(2026-07-09 核实)\n\n四界面在 **Web 前端(`frontend/src`)已大面积落地**,不是纯文档:\n\n| 界面 | Web 实现 | Desktop 实现 |\n|------|----------|--------------|\n| 健康中心 | ✅ `HealthCenterView.vue` + `HealthCenterStats`/`HealthChannelTable`/`HealthChannelDetail`/`ProfileChangelogTimeline`,路由 `/health` | ❌ 无 |\n| 订阅中心 | ✅ `SubscriptionsView.vue` + `SubscriptionPlanTable`,CRUD + 刷新余额,路由 `/subscriptions` | ⚠️ 独立旧实现 `subscriptions/SubscriptionTab.vue`(与 Web 两套代码) |\n| 渠道中心 | ✅ `ChannelOrchestration.vue`(六态 `ChannelHealthBadge`、来源 chips、`autoManaged` 图标齐备) | ❌ 未同步 |\n| 管理面板 | ⚠️ `AutopilotModePanel.vue` 仅暴露 mode/killSwitch/costPreference | ❌ 无 |\n| 驾驶舱 | ✅ `CockpitView.vue`(观测型总览:健康统计+余额汇总+渠道推荐) | ⚠️ `cockpit` tab 渲染的是会话 Dashboard,职责不同 |\n\n**与本设计意图的关系**(对齐用户「自动调度为主、废旧手动驾驶舱」的诉求):\n- **旧的「手动调度驾驶舱」在 Web 端已不存在**。`CockpitView` 已是 §8.7 定义的观测型总览,只回答健康/省钱/风险/待办四问题,不做渠道配置编辑,符合「程序自动选、人只观测+有限干预」的方向。无需再废除,只需继续收敛为纯观测。\n- **统一提交入口已存在**(§8.4 快速添加 + §8.5 订阅中心),new-api 集成(§8.5.1)在此之上把「填 baseURL+key」进一步自动化。\n\n**前端演进待办**(本设计新增/明确):\n1. **补 `ManualRoutingIntent` 前端**:这是用户要的「有限偏向性干预」,后端已有、两个前端均未实现(§8.3 试用意图入口尚缺)。\n2. **Desktop 前端对齐 Web**:健康中心/渠道自动托管/管理面板/观测型驾驶舱在 Desktop 几乎空白,需同步或复用 Web 组件,避免长期维护两套。\n3. **管理面板补字段**:暴露 `l2ProbeEnabled`、探测预算、new-api/多来源订阅 adapter 开关(§8.5.2)等,当前只有 3 个字段。\n4. **订阅中心接 §8.5.1/§8.5.2**:新增「new-api」订阅类型与 Provider Adapter 能力矩阵驱动的动态表单。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.5.1 new-api 订阅集成(统一账号接入)\n\n**目标**:用户在订阅中心手填一个 new-api(及其 fork)账号的「系统访问令牌」即可,CCX 自动用该令牌创建代理专用 key、拉取分组倍率、查询额度余额,并把「站点 baseURL + 新建 key」自动落成一个上游渠道,交给 Autopilot 探测与调度。\n\n**范围约束**:\n- 只使用 new-api 用户在「个人设置」生成的 **系统访问令牌(access_token)** 一种凭据;令牌用于 API 调用的身份验证,属敏感数据,须妥善加密存储。\n- **不做** cookie 登录 + 2FA + 导出后台渠道明文 key 的管理员迁移路径(all-api-hub 的 newApiSession 能力),本设计不涉及。\n- new-api 归入 `OriginType=relay`、`OriginTier=second`(中转站),信任等级不因自动化提升;实时质量仍由运行画像决定。\n- 认证令牌像其他订阅凭据一样由用户手动录入,CCX 不代替用户在 new-api 侧注册或登录。\n\n**认证与凭据模型**:\n\nnew-api family 的管理接口鉴权(依据 all-api-hub 传输层实现):\n\n```text\nAuthorization: Bearer # 系统访问令牌;raw 模式可去掉 Bearer 前缀\nNew-API-User: # 当前用户 ID(必带)\nUser-id: # fork 兼容回退(Veloera/voapi/Super-API 等)\nContent-Type: application/json\n```\n\n统一响应信封:`{ \"success\": bool, \"data\": T, \"message\": string }`;解析时先判 `success`,再取 `data`。\n\n**SubscriptionProfile 扩展**:\n\n在 §3.2.2 的 `SubscriptionProfile` 基础上,为可自动接入的订阅新增 provider 集成字段(沿用「默认关闭 + 显式 opt-in」守则):\n\n```go\n// SubscriptionProfile 新增字段(复用 Phase 4 Item 6 的 SubscriptionAutoRefresh 开关)\ntype SubscriptionIntegration struct {\n Kind string `json:\"kind\"` // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | ...\n BaseURL string `json:\"baseUrl\"` // new-api 站点地址(同时作为新建渠道的上游 baseURL)\n AccessToken string `json:\"-\"` // 系统访问令牌;加密存储,不出 API 响应\n UserID string `json:\"userId\"` // New-API-User 头所需\n AuthTokenMode string `json:\"authTokenMode,omitempty\"` // \"bearer\"(默认) | \"raw\"\n // 自动建 key 的模板\n ProvisionKeyName string `json:\"provisionKeyName,omitempty\"` // 默认 \"ccx-autopilot\"\n ProvisionGroup string `json:\"provisionGroup,omitempty\"` // 建 key 时指定分组,空=默认分组\n ProvisionModels []string `json:\"provisionModels,omitempty\"` // model_limits,空=不限制\n // 自动同步产物(回填,只读展示)\n ProvisionedTokenID int `json:\"provisionedTokenId,omitempty\"`\n LinkedChannelUID string `json:\"linkedChannelUid,omitempty\"`\n}\n```\n\n`AccessToken` 必须与现有敏感字段一样走加密存储,序列化到前端时脱敏(仅显示尾部若干位)。\n\n**new-api 接口清单**(本集成实际调用的子集,路径以主线 new-api 为准,字段名保持与上游一致):\n\n| 用途 | 方法 | 路径 | 请求体 | 响应关键字段 |\n|------|------|------|--------|--------------|\n| 校验令牌 + 查用户/余额 | GET | `/api/user/self` | — | `data.id`、`data.username`、`data.quota`(剩余额度)、`data.used_quota` |\n| 拉分组倍率 | GET | `/api/user/self/groups` | — | `data: { : { desc, ratio } }` |\n| 站点全部分组 | GET | `/api/group` | — | `data: string[]` |\n| 账号可用模型 | GET | `/api/user/models` | — | `data: string[]` |\n| 创建代理 key | POST | `/api/token/` | `CreateTokenRequest`(见下) | `data.key` / `success` |\n| key 列表 | GET | `/api/token/?p={p}&size={n}` | — | `data.items[]`(含 `id/key/name/status/remain_quota`) |\n| 更新 key | PUT | `/api/token/` | `{ ...token, id }` | `success` |\n| 删除 key | DELETE | `/api/token/{id}` | — | `success` |\n\n`CreateTokenRequest`:\n\n```json\n{\n \"name\": \"ccx-autopilot\",\n \"remain_quota\": 0,\n \"expired_time\": -1,\n \"unlimited_quota\": true,\n \"model_limits_enabled\": false,\n \"model_limits\": \"\",\n \"allow_ips\": \"\",\n \"group\": \"\"\n}\n```\n\n`quota` 是 new-api 内部额度单位(非美元),换算比例读 `GET /api/status` 的 `price` / `PaymentUSDRate`(无鉴权),或允许用户在订阅中手填换算率;MVP 直接以 quota 原值展示 + 站点换算率估算余额。\n\n**接入流程**(订阅中心「新增订阅 → 类型选 new-api」):\n\n```text\n1. 用户输入:站点 baseURL + 系统访问令牌(access_token) + userId\n (userId 可选自动获取:先用令牌调 /api/user/self 拿 data.id 回填)\n2. 校验:GET /api/user/self\n ├─ success=false / 401 → 令牌无效或 userId 不匹配,报错停止\n └─ 成功 → 记录 username、quota,创建 SubscriptionProfile(provider=new_api, originTier=second)\n3. 拉分组:GET /api/user/self/groups → 写入 GroupMultipliers({group: ratio})\n GET /api/user/models → 记录账号可用模型,供渠道 supportedModels 参考\n4. 建 key:POST /api/token/(按 ProvisionKeyName/Group/Models 模板)\n ├─ 已存在同名 ccx-autopilot key → 复用其 id,不重复创建(列表比对 name)\n └─ 新建成功 → 回填 ProvisionedTokenID,取 data.key 作为渠道 apiKey\n5. 自动建渠道:用 baseURL + 新建 key 创建一个上游渠道\n ├─ channelKind 由用户选择或按站点探测(messages/chat/responses/gemini)\n ├─ status=unknown,autoManaged=true,OriginType=relay\n ├─ 建立 SubscriptionUID → ChannelUID 链接(回填 LinkedChannelUID)\n └─ 触发 §8.4 的 Discovery + 能力测试标准流程\n6. 完成:订阅卡片显示余额、分组倍率、已绑定渠道、探测进度\n```\n\n关键点:new-api 集成**不额外造探测逻辑**,第 5 步之后完全复用现有 §8.4「添加并探测」链路;集成层只负责「令牌 → key + 分组 + 余额 + baseURL」的自动填充,把手填 baseURL/key 的动作自动化掉。\n\n**刷新与同步**(复用 `POST /api/subscriptions/{uid}/refresh`):\n\n| 触发 | 动作 |\n|------|------|\n| 手动刷新余额 | `GET /api/user/self` 更新 `Balance` + `BalanceUpdatedAt` |\n| 定时刷新(可选,opt-in) | 复用 Phase 4 Item 6 `SubscriptionAutoRefresh`;new-api 作为 `relay` provider adapter 纳入,默认关闭 |\n| 分组倍率变更 | 刷新时对比 `/api/user/self/groups`,`GroupMultipliers` 变化 → 更新并提示「套餐倍率已变」 |\n| key 失效检测 | 渠道侧探测到该 key 全 401 → 订阅卡片提示「key 可能已删除/禁用」,提供「重新建 key」一键动作(重跑第 4-5 步,复用旧渠道,只换 apiKey) |\n| 可用模型变更 | `/api/user/models` 结果哈希变化 → 触发对应渠道重新 Discovery |\n\n**安全与边界**:\n- `access_token` 权限等同 new-api 账户,须加密存储、脱敏展示、禁止写入日志;刷新失败连续 N 次自动停用该订阅的自动刷新,只保留手动。\n- 自动建 key 默认 `unlimited_quota=true` 但可由用户改为限额模板;建 key 是有副作用的写操作,须在接入前明确告知用户「将在 new-api 侧创建一个名为 ccx-autopilot 的令牌」。\n- new-api 是 `OriginTier=second`,**不得**用作隐私敏感的 routing advisor / classifier(遵守 §3.2.1 约束)。\n- fork 兼容:请求同时带 `New-API-User` 与 `User-id` 头;响应信封字段名(`success/data/message`)在部分 fork 可能不同,adapter 需容错解析,失败时回退手动录入。\n- 一个 new-api 订阅默认只自动建/绑定一个渠道;用户如需该站多协议渠道,可在同订阅下手动增建,共用同一 key。\n\n### 8.5.2 订阅 Provider Adapter 框架(多来源余额与接入)\n\n§8.5.1 的 new-api 集成不是特例,而是「订阅 Provider Adapter」框架的一个实现。Phase 4 Item 6 已落地 openai/anthropic/google 的余额刷新 adapter,本框架把它抽象成统一接口,让 new-api 及后续来源(GitHub Copilot、Codex、Nvidia、MiniMax、DeepSeek 等)按相同契约接入,避免每加一个来源就散落一套逻辑(DRY)。\n\n**统一接口**:\n\n```go\n// backend-go/internal/autopilot/subscription/adapter.go\ntype ProviderAdapter interface {\n Kind() string // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | \"copilot\" | \"deepseek\" | ...\n\n // 校验凭据,返回账号身份(用户名/账户标识);失败即凭据无效\n Verify(ctx context.Context, cred Credential) (AccountInfo, error)\n // 查余额/额度;不支持返回 ErrUnsupported,由 UI 降级为手动录入\n FetchBalance(ctx context.Context, cred Credential) (Balance, error)\n // 拉分组倍率;仅 new-api 类中转有,官方直连返回 ErrUnsupported\n FetchGroups(ctx context.Context, cred Credential) (map[string]GroupRatio, error)\n // 自动创建代理 key;官方直连通常不支持(key 即用户 API key),返回 ErrUnsupported\n ProvisionKey(ctx context.Context, cred Credential, tmpl KeyTemplate) (ProvisionedKey, error)\n // 可用模型列表;用于渠道 supportedModels 参考\n FetchModels(ctx context.Context, cred Credential) ([]string, error)\n}\n```\n\n每个能力独立、可选:adapter 只实现自己支持的部分,不支持的统一返回 `ErrUnsupported`(接口隔离原则),UI 据此隐藏/降级对应入口。所有 adapter 共用 `SubscriptionAutoRefresh` 的 opt-in 开关与 `Validate()` 兜底,与 Phase 4 现有防御模式一致。\n\n**能力矩阵**(✅ 支持 / ⚠️ 部分或需确认 / ❌ 不支持,走手动):\n\n| 来源 | Kind | 验证 | 查余额 | 拉分组 | 自动建 key | OriginTier |\n|------|------|:----:|:------:|:------:|:----------:|------------|\n| new-api family | `new_api` | ✅ | ✅ quota | ✅ | ✅ | second |\n| OpenAI 官方 | `openai` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Anthropic 官方 | `anthropic` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Google 官方 | `google` | ✅ | ⚠️ | ❌ | ❌ | first |\n| GitHub Copilot | `copilot` | ⚠️ token | ❌ 无额度概念 | ❌ | ❌ | first |\n| Codex plan | `codex` | ⚠️ | ⚠️ 用量窗口 | ❌ | ❌ | first |\n| Nvidia NIM | `nvidia` | ✅ | ⚠️ credits | ❌ | ❌ | first |\n| MiniMax | `minimax` | ✅ | ⚠️ | ❌ | ❌ | first |\n| DeepSeek | `deepseek` | ✅ | ✅ balance API | ❌ | ❌ | first |\n\n说明:官方直连来源普遍**不支持自动建 key**(用户的 API key 本身就是凭据,直接作为渠道 apiKey 录入即可)、也**没有分组倍率**(价格按官方定价表)。它们的 adapter 价值集中在「验证 + 查余额/用量窗口」,把 §3.2.4 UsageWindow 的官方用量 API 来源坐实。DeepSeek 有独立余额查询端点,可完整支持余额。Copilot 属订阅制、无 token 余额概念,只做验证 + 用量提示。\n\n**接入分层**(YAGNI:不强求一次做全,按价值排期):\n\n```text\nP0(本轮):new_api(完整:验证+余额+分组+建key+自动建渠道)\n —— 覆盖用户最主要的中转站场景\nP1:deepseek(验证+余额)、官方三家余额已在 Phase 4 完成 → 补 UsageWindow 对齐\nP2:nvidia / minimax(验证+credits/余额)\nP3:copilot / codex(验证+用量窗口提示,无余额)\n```\n\n每个来源的具体端点在实现时于 `adapter.go` 各 provider 文件内落地,本设计只固定接口契约与能力矩阵,避免文档与上游 API 变动强耦合。新增来源 = 实现 `ProviderAdapter` + 在能力矩阵登记,不改框架。\n\n**能力缺失与降级**:\n- adapter 任一能力返回 `ErrUnsupported` → 订阅中心对应字段回退为手动录入(余额手填、倍率手填),不报错、不阻塞接入。\n- adapter 验证失败/超时 → 订阅可仍以「手动模式」创建,仅失去自动刷新与自动建 key,其余流程(手填 baseURL+key 建渠道)不受影响。\n- 官方来源无「自动建 key」时,接入流程退化为 §8.4 标准快速添加:用户填 API key,adapter 只补验证与余额。\n- 所有自动刷新遵守 `SubscriptionAutoRefresh` 默认关闭;连续失败自动停用自动刷新并在驾驶舱「人工待办」提示。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3,\n \"providerTimePricing\": {\n \"deepseek\": {\n \"effectiveFrom\": \"2026-07-20T00:00:00+08:00\",\n \"timeZone\": \"Asia/Shanghai\",\n \"peakMultiplier\": 2,\n \"peakWindows\": [\n { \"start\": \"09:00\", \"end\": \"12:00\" },\n { \"start\": \"14:00\", \"end\": \"18:00\" }\n ]\n }\n }\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n`providerTimePricing.effectiveFrom` 使用 RFC3339;修改该值即可调整激活时间,设为空字符串则停用对应规则。\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**Phase 3A 状态(2026-07-09)**:低风险、不改真实路由语义的子集已完成并接入真实链路——`Manager.collectAll()` 新旧画像对比产出变更事件(`DetectProfileChanges`)、`ProfileChangelogStore`(环形内存 + SQLite,30 天机会性清理)、`EventHub` 内存 pub/sub、`GET /api/health-center/changelog`(历史)+ `GET /api/health-center/events`(WebSocket 实时推送)、`AutoDiscoveryRunner` 发布 `discovery_completed`/`auto_mapping_applied`、前端 `ProfileChangelogTimeline.vue` 接入健康中心页并支持断线自动重连。均为只读展示,未修改 `SmartRouter`/`EndpointAttemptPolicy` 调度逻辑。落地时顺带发现并修复一个真实缺口:浏览器原生 WebSocket 无法设自定义 header,为此给 `middleware.getAPIKey` 增加了 `Sec-WebSocket-Protocol` 鉴权回退(业界标准做法,仅新增一个 key 来源,不降低现有校验)。\n\n**Phase 3B(已全部完成,2026-07-09)**:模型自动映射、自动恢复探测、晋升/降级机制会直接改变真实调度决策,风险明显高于 3A,四个子项已分批全部完成。§12.2 P1.5 的 SQLite schema version / 版本化 migration / `OriginType`&`OriginTier` 旧配置 backfill / `killSwitch`+按 task class/channel disable **已于 2026-07-09 补强完成**(详见 §12.2 P1.5)。**Phase 3B-1(自动恢复探测补全)已于 2026-07-09 完成**:修复了 L2 探测子系统里此前从未打通的两个真实 bug——① `collectAll()` 每轮把新构造的画像整行 Upsert,会把 `ProbeWorker` 刚写入的 `LastProbeAt`/`ProbeSuccess`/`ConsecutiveProbeSuccess` 等字段清零,导致 6 小时探测冷却期形同虚设(修复:`carryForwardProbeFields` 在 Upsert 前搬运 Probe* 字段,不影响 L1 诊断字段的现有覆盖逻辑);② `extractRawAPIKey` 是返回 `KeyMask` 掩码值的占位 stub,探测请求实际带的是假 key(修复:新增 `APIKeyResolver` 回调 + `Manager.ResolveAPIKey`,通过遍历配置里对应渠道的 `APIKeys` 逐个计算 `KeyHashFromAPIKey` 比对来还原明文;resolver 未注入或未命中时 fail-open 跳过探测且不消耗预算)。同时补齐了 `degraded/limited → healthy` 的连续成功恢复路径(新增 `KeyEndpointProfile.ConsecutiveProbeSuccess` 计数器 + `HealthCheckConfig.ProbeRecoveryThreshold`,默认连续 2 次探测成功才恢复,避免单次探测噪声导致状态抖动;探测失败计数器清零)。**Phase 3B-3(StabilityTier 晋降级滞后)已于 2026-07-09 完成**(提交 664d3fff):在原本每轮从瞬时 1h 窗口重算的无记忆 `StabilityTier` 上加一层连续窗口滞后——新增 `KeyEndpointProfile.EffectiveStabilityTier`/`StabilityPendingTier`/`StabilityPendingStreak` 三字段 + 纯函数 `applyStabilityHysteresis`(对称阈值:连续 N 轮同值才切换生效档,`HealthCheckConfig.StabilityHysteresisWindows` 默认 2;首次画像直接采用,不等待阈值,避免新渠道卡在零值),`AggregateChannelProfile` 改读 `EffectiveStabilityTier`(空则回退 `StabilityTier`)。晋升/降级机制的数据模型仅限于 StabilityTier,不改变 QualityTier/SpeedTier/CostTier 语义。**Phase 3B-2(模型自动映射闭环)已于 2026-07-09 完成**(提交 0f02e988,随后修复一处 critical 集成 bug,提交 856372c4):新建 `ModelProfileStore`(SQLite,`model_profiles` 表)+ 扩展 `AutoDiscoveryRunner` 为每个发现模型写入 `ModelProfile`(仅 `AutoManaged` 渠道);新建 `ModelResolver` 实现 §5.4 五步逻辑(手动 `RedirectModel` 短路 → `GetModelProfiles` → `filterByCapabilityFloor` → `rankBySimilarity`);`scheduler` 包新增 `ModelSupportResolverFunc` hook(fail-open,未注入或返回不支持时严格回退到原 `ExplainModelSupport`);`endpoint_policy.go` 的 `resolveMappedModel`(此前是永不触发的死代码——`profile.ModelMapping` 从未被写入)现调用 `ModelResolver`;`handlers/common` 在 `RedirectModel` 之后应用 `MappedModel` 覆盖请求体,并按 `EchoMappedModel` 配置回显 `X-CCX-Mapped-Model`/`X-CCX-Original-Model`/`X-CCX-Mapping-Source` 响应头。三条件安全门控:`ModelMapping.AutoResolve==true`(**默认由死配置的 true 改为 false**)AND `RoutingMode ∈ {assist, auto}` AND 无 `KillSwitch`,三者任一不满足则所有新增代码路径行为与今天字节级一致;且仅 `AutoManaged` 渠道触发,手动配置渠道完全不受影响。对抗性复核发现并修复一个真实 critical bug:`scoreEndpointForKey` 用空 `channelUID` 计算 `EndpointUID` 并写入 `modelByUID`,而 `handlers` 层用真实 `channelUID` 查 `modelByUID`,两者哈希永不相等导致 `MappedModel` 在 key 级调度路径上完全查不到(提交 856372c4 修复:命中画像后改用 `profile.EndpointUID`,新增回归测试锁定该不变量)。至此 Phase 3B 四个子项(自动恢复探测、StabilityTier 滞后、模型自动映射、active filter 前置判定)全部完成;SLO regression 自动回滚仍是独立子系统,留待后续单独立项。\n\n**范围**:\n- [x] 运行时指标驱动画像实时更新(Phase 3A:`collectAll()` 每轮 diff + changelog + WebSocket 推送)\n- [x] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)—— Phase 3B-2(2026-07-09 完成,提交 0f02e988 + 856372c4)\n- [x] active model filter / context filter 支持自动映射前置判定 —— Phase 3B-2:`scheduler.ModelSupportResolverFunc` fail-open 接入 `select.go`\n- [x] 自动恢复探测(limited/dead → healthy)—— Phase 3B-1(2026-07-09 完成):`APIKeyResolver` + `carryForwardProbeFields` + `ConsecutiveProbeSuccess`/`ProbeRecoveryThreshold`\n- [x] 晋升/降级机制(连续成功→升级,连续失败→降级)—— Phase 3B-3(2026-07-09 完成,提交 664d3fff):`StabilityTier` 连续窗口滞后(`EffectiveStabilityTier`),暂不含 QualityTier/SpeedTier/CostTier\n- [x] WebSocket 推送画像变更事件(Phase 3A:`GET /api/health-center/events`)\n- [x] 前端画像变更历史/时间线(Phase 3A:`ProfileChangelogTimeline.vue`)\n\n**预估工期**:2-3 周(3A、3B-1、3B-2、3B-3 均已完成;advisor 与全局 auto 的 SLO 回滚均已补齐)\n\n> 状态校正(2026-07-17):上方 Phase 3B 历史说明中的“SLO regression 自动回滚待单独立项”已完成;advisor 回滚与全局 auto 上线闸门/自动降级是两个独立保护层。\n\n### Phase 4:高级特性\n\n**Phase 4 状态(2026-07-09,全部完成)**:8 项全部实现并接入真实链路,均遵循\"默认关闭 + 显式 opt-in\"安全守则。\n\n**范围**:\n- [x] 多维度标签系统扩展(用户自定义标签)——Item 1\n- [x] 成本报表:按用户/模型/渠道/key 统计真实有效成本——Item 2(新增 `proxyKeyMask` 身份贯穿链路:`middleware.GetProxyKeyMask` → `ChannelLog`/`RequestRecord` → SQLite `proxy_key_mask` 列,供 Item 4 复用)\n- [x] Trusted advisor SLO regression 自动回滚——Item 3(默认关闭;`SLORollbackConfig`,连续 degrading 窗口数默认 3,不做自动恢复以避免震荡)\n- [x] 渠道推荐:根据使用模式推荐新渠道——Item 4\n- [x] 批量渠道管理(导入/导出/模板)——Item 5\n- [x] 订阅中心 provider adapter:可选自动刷新余额/套餐状态——Item 6(默认关闭;仅 openai/anthropic/google 且显式填写 BillingAPIKey 的订阅生效,中转/公益渠道继续手动维护)\n- [x] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板——Item 7\n- [x] A/B 测试:低比例统计抽样双发——Item 8(默认关闭,默认 1% 抽样 + 每小时 60 次硬预算;主请求路径不受影响,影子请求在主响应返回后异步发起)\n\n**关键实现说明**:\n- Item 3/6/8 均在 `AutopilotRoutingConfig` 新增独立配置块(`SLORollback`/`SubscriptionAutoRefresh`/`ABTest`),`Validate()` 统一做负值/越界兜底,保持与既有字段一致的防御模式。\n- Item 4 直接复用 Item 2 的 `proxyKeyMask` 身份标记,未重新发明身份识别机制。\n- **全局 auto 安全闸门(2026-07-17)**:请求终态按 15 分钟窗口无偏聚合,详细 trace 继续抽样;管理 API 只在 shadow/assist 已积累至少 500 个有效请求、24 小时连续观测且成功率/fallback/fail-open/p95 均达标时允许切换 `auto`。后台连续检测三个已完成 auto 窗口,相对最近七天安全模式基线持续恶化时,持久化降级到 `assist` 并记录 `autopilot_auto_safety_events`,不自动恢复。\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- ~~SQLite schema version 和幂等 migration。~~ **已完成**:`ensureSchemaVersion` 基于 `PRAGMA user_version`,当前 7 张表定版为 v1 基线;库版本高于代码版本时返回 error(fail-closed,不做任何写操作),交给下游 fail-open 分支接管。\n- ~~旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。~~ **已完成**:`ensureOriginBackfill` 只补 `\"unknown\"`,不做任何基于 URL/名称的猜测推断;已接线到 `Profiler.DeriveEndpointProfile`,`BreakTieByOriginTier` 现在能吃到真实数据(此前一直是 fallback 的 `unknown`)。\n- ~~profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。~~ **已确认满足并测试锁定**:`main.go` 现有结构本就是 fail-open(`NewProfileStore`/`NewManager` 出错只打日志、`autopilotManager` 保持 `nil`,下游全部有 `nil` 守卫),本次新增 `schema_migration_test.go` 用测试锁定这条不变量。\n- ~~全局 `killSwitch`、按 task class disable、按 channel disable。~~ **已完成**:新增 `AutopilotRoutingConfig.DisabledTaskClasses`/`DisabledChannelUIDs`;`CandidateFilterFor`/`executeFilter`/`BuildPlan` 三处一致接入,`smart_router_invariant_test.go` 覆盖\"task class 禁用后回退默认调度\"\"channel 禁用后不出现在候选里\"\"dry-run 与真实路径行为一致\"三条不变量。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。**部分完成**:`ReleaseController.EvaluateAndApplyRegression` 已实现三窗口回归检测和 `SafetyOverride` 降级机制;`RoutingModeActive` 已加入配置枚举和 `IsAutopilotActive`。\n\n#### P1.6 Trace v2 契约与灰度发布\n\n> 对应实施计划:`docs/superpowers/plans/2026-07-23-autopilot-trace-rollout.md`\n\n**Trace v2 核心变更:**\n\n- `RoutingDecisionTrace` 增加 `SchemaVersion`、`TraceRevision`、`RequestCorrelationId`、`ReleaseID`、`PolicyFingerprint`、`TargetMode`/`EffectiveMode`/`Cohort`、`SchedulerDecision`、`EndpointAttempts` 字段。\n- 新增 `TraceDetailV2`、`TraceSummary`、`SchedulerDecisionSummary`、`EndpointAttemptSummary` DTO;使用显式 DTO 落盘而非直接 JSON 化运行时结构。\n- `ComparisonStatus` 三态枚举(`matched`/`mismatched`/`uncompared`)替代裸 `bool`。\n- `SanitizeForPersistence` 与 `SanitizeForResponse` 双边界脱敏;`GenerateTraceUIDv2` 使用 `crypto/rand` 碰撞安全生成。\n- `RoutingReleaseSnapshot` 不可变发布快照,入口冻结后续只读。\n\n**SQLite v6 迁移:**\n\n- trace 表新增列:`schema_version`、`trace_revision`、`request_correlation_id`、`release_id`、`policy_fingerprint`、`persistence_class`、`details_json`。\n- 窗口表重建主键:增加 `release_id`/`policy_fingerprint`/`cohort` 维度,新增 `compared_count`/`matched_count`/`mismatch_count`/`uncompared_count` 全量比较计数。\n- safety event 表增加 `release_id`/`policy_fingerprint` 字段。\n\n**灰度发布状态机:**\n\n```text\noff --管理员启用--> shadow --门槛满足--> assist --门槛满足--> auto --100% 稳定--> active\n```\n\n| 状态 | 行为 | 放量规则 |\n|---|---|---|\n| `off` | 不调用 Autopilot | rolloutPercent=0 |\n| `shadow` | 计算+记录,不影响调度 | rolloutPercent=0,安全基线 |\n| `assist` | 重排候选,不删除 | rolloutPercent 1→100 |\n| `auto` | 硬约束过滤+重排 | rolloutPercent 1→100 |\n| `active` | 与 auto 语义相同 | rolloutPercent=100,1% shadow control |\n\n**ReleaseController**(`release_controller.go`):\n\n- 集中处理相邻状态迁移校验(逐级晋升,降级随时允许)。\n- `ComputeCohort` 使用 session ID / request correlation ID 与 `rolloutSeed` 做稳定哈希分桶。\n- `EvaluateAndApplyRegression` 检查三窗口回归并应用 `SafetyOverride`。\n- `AllowedTransition` 禁止跳级,`modeRank` 确定顺序。\n\n**只读 Trace API:**\n\n| 接口 | 返回 | 约束 |\n|---|---|---|\n| `GET /api/autopilot/traces` | `TraceSummary[]` | 游标分页、release/cohort/mode 过滤、partial 标记 |\n| `GET /api/autopilot/traces/:traceUid` | `TraceDetailV2` | 404=未找到/已过期/未采样;503=存储不可用 |\n| `GET /api/autopilot/traces/stats` | `TraceStatsResponse` | 三态比较统计、Mode/TaskClass 分布 |\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | shadow 样本/准确率/critical misroute 门槛;active 后 SLO regression 自动 rollback;全局 kill switch |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "3f59abd84cfee9c40513c8d3d136153b257f7b62": {"t": 1785759340.0166204, "n": 165083, "h": "8b6e041acac3ba71", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.2.3 同订阅多 Key:能力共享与配额负载均衡\n\n**问题**:官方订阅 plan(Claude Max、Codex plan 等)允许用户持有多个 key,或购买多份相同套餐。若每个 endpoint 独立探测与配置,探测预算被浪费、配置重复;请求量大时还需要在同 plan 的多个 key 之间按官方 RPM 限速自动负载均衡,而不是打满第一个 key 才溢出到下一个。\n\n**能力检测去重(同一套餐只需一套能力检测和配置)**:\n\n```text\nSubscriptionUID 相同的 endpoint 共享一份「订阅级能力画像」:\n 模型列表 / vision / tool / reasoning 支持 / 协议兼容开关 → 只探测一次,\n 存在 SubscriptionProfile.SharedCapability,endpoint 画像以引用方式继承。\n\n仍然 per-endpoint 维护的:健康状态、延迟、熔断、用量窗口\n (key 可能被单独封禁/限速,能力相同不代表运行态相同)\n\n继承失效条件:某 key 实测模型列表哈希与订阅级不一致(GroupChangeDetector 信号)\n → 该 key 脱离继承、独立探测,并在 UI 标记「与套餐能力不一致」警告。\n```\n\n**配额负载均衡(Phase 1 shadow 展示,Phase 2 生效)**:\n\n```text\n每 key 维护配额余量视图:\n remainingRPM = officialRPM − 当前窗口已用\n officialRPM 来源优先级:内置 manifest > 用户配置 > RateLimitDiscoverer 建议\n\n同订阅多 key 时按「剩余余量加权」分配请求(余量大者优先、\n窗口最早重置者兜底),替代顺序打满:\n 仅当渠道 autoManaged 且同一 SubscriptionUID 下 key 数 > 1 时启用;\n 否则保持现有 sequential/random key 顺序策略不变。\n\n某 key 的窗口配额耗尽 → 临时降权并切到同订阅其他 key,\n不熔断整个渠道(这是配额耗尽,不是故障)。\n```\n\n### 3.2.4 订阅用量窗口 (Usage Windows)\n\n部分官方 plan 提供 5h / 日 / 周 / 月等窗口的用量查询与限制(如 Claude 订阅的 5h 滚动窗口 + 周配额)。在 key 的位置展示这些窗口,帮助用户掌握使用情况:\n\n```go\ntype UsageWindow struct {\n Window string `json:\"window\"` // \"5h\" | \"day\" | \"week\" | \"month\"\n Used float64 `json:\"used\"` // 已用量\n Limit float64 `json:\"limit\"` // 上限,0 = 未知\n Unit string `json:\"unit\"` // requests | tokens | credits | percent\n ResetsAt time.Time `json:\"resetsAt\"` // 窗口重置时间\n Source string `json:\"source\"` // official_api | response_header | local_metering\n FetchedAt time.Time `json:\"fetchedAt\"`\n}\n// KeyEndpointProfile 与 SubscriptionProfile 均增加 UsageWindows []UsageWindow\n```\n\n数据来源优先级:\n\n```text\n1. 官方用量 API:有 provider adapter 的 plan 主动拉取(§Phase 4 订阅 adapter 的可提前子集)\n2. 响应头:anthropic-ratelimit-* / x-ratelimit-* 等(复用 RateLimitDiscoverer 的信号 tap)\n3. 本地计量兜底:CCX 按 key 累计请求数/token(误差来自窗口起点对齐,标注 Source=local_metering)\n```\n\nUI 要求:key 行内显示主窗口(5h)用量进度条,悬浮展开全部窗口;用量 >80% 黄色预警、超限红色并显示预计重置时间。调度联动(Phase 2):窗口余量进入配额负载均衡权重,窗口耗尽的 key 临时降权而非熔断。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n#### 4.2.1 内置模型清单 (Builtin Models Manifest)\n\n**问题**:部分官方订阅 plan 的入口没有正确的 models 接口——要么不存在(404)、要么需要额外权限、要么返回与订阅实际可用模型不符的列表(典型:Claude 订阅 OAuth 入口、Codex plan 入口、部分官方企业网关)。对这些 baseURL 做模型发现会失败或产出错误画像。\n\n**方案**:CCX 内置「已知官方 baseURL → 模型清单」映射表,命中时直接返回内置内容,完全跳过上游 models 接口:\n\n```go\n// backend-go/internal/config/builtin_models_manifest.go(生成式,随模型注册表更新)\ntype BuiltinModelsManifest struct {\n BaseURLPattern string `json:\"baseUrlPattern\"` // 精确 host 或前缀匹配,如 \"api.anthropic.com\"\n ServiceType string `json:\"serviceType\"` // messages/responses/…\n PlanHint string `json:\"planHint,omitempty\"` // 订阅类型提示(如 \"claude_subscription\")\n ModelIDs []string `json:\"modelIds\"` // 该入口实际可用的模型清单\n DisableProbe bool `json:\"disableProbe\"` // true 时 Discovery 跳过 GET /v1/models\n}\n```\n\n**两个生效位置**:\n\n```text\n1. Discovery(§4.2 流程第 2 步之前):\n baseURL 命中 manifest 且 DisableProbe=true\n → 跳过 GET /v1/models,直接用 ModelIDs 生成 ModelProfile[]\n → ProfileSource = \"builtin_manifest\",不再因 models 接口失败而误判 misconfigured\n\n2. /v1/models 代理响应(现有 Models API):\n 客户端向 CCX 请求 /v1/models 时,聚合结果中该渠道的贡献\n 直接取内置清单,不向上游发起 models 请求\n (渠道显式配置了 supportedModels 时仍以用户配置优先)\n```\n\n**维护与覆盖顺序**:\n\n```text\n渠道显式 supportedModels(用户手动) > 内置 manifest > 上游 models 接口实探 > 内置候选模型回退\n```\n\nmanifest 与模型注册表同源生成(`generated_model_registry.go` 同级),新模型发布时随注册表一起更新;HealthAnalyzer 对命中 manifest 的 endpoint 不将「models 接口 404/403」计入 misconfigured 信号。\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n MappedEffort EffortLevel // 自动映射的 effort 级别(空=未决定)\n MappedEffortDecided bool // effort 是否由 Autopilot 决定\n EndpointUID string // 稳定 endpoint ID\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n\n // ResolvedModelByEndpointUID(旧 API):仅返回模型名,向后兼容。\n // ResolvedTargetByEndpointUID(新 API):返回 model + effort 原子决策。\n // 两者从同一份 targetByUID 派生,避免两个真值源。\n ResolvedModelByEndpointUID func(endpointUID string) string\n ResolvedTargetByEndpointUID func(endpointUID string) *ResolvedRouteTarget\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20;assist 下 dead/misconfigured 额外 -100 并保留在队尾\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法细分时进入 `general`;有规范 Knowledge 基准则使用其软评分,缺少直接证据时仍取 0.5 中性值。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n**手动 L3 第一版状态(2026-07-15)**:已实现 `pq-v1-20260715` 固定 JSON canary,覆盖\nMessages、Chat Completions、Responses 与 Gemini 上游协议。调用方只能指定 `endpointUid`、\n`modelId` 和 1~3 次重复数,不能注入 prompt。评分仍按 40% 完整性、30% 语义正确性、\n15% 严格 JSON 格式、15% 延迟执行;第一版使用非流式完整响应延迟作为首 token 延迟代理,\n后续引入流式采样时再替换该维度。单次有效样本 confidence=0.6,最多三次提升到 0.8;\n失败样本按 0 分计入均值,全部失败不写画像。\n\n安全边界:仅管理 API 手动触发,不启动自动 worker;当前进程按 UTC 日设置 12 次硬预算,\n请求前原子预留,单次 90 秒超时、响应体上限 64 KiB。同一 endpoint×model 不并发执行;\n单次请求最多允许 2048 个输出 token,以容纳推理模型的隐藏思考并限制最坏额度消耗。\n对声明可控推理的模型固定采用最低可用 effort;Claude 兼容端点若只暴露\n`reasoning_content` 而未声明 thinking 模式,则与 capability-test 一致显式关闭 thinking。\n原始模型输出和明文 Key 既不落盘也不返回,只保存分数、canary 版本、置信度和脱敏判定。\n`user_feedback` 仍高于 `probe`,自动发现重建 ModelProfile 时必须保留既有质量证据。\n跨进程共享预算与 LLM-as-judge/语义向量评分仍属于后续阶段。\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设 + 自定义\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n| `custom` | 用户自定义 | 用户自定义 | 高级用户微调,范围 0.0 ~ 3.0 |\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法细分的通用任务;缺少基准证据时中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general;有规范 Knowledge 基准时使用其软评分,缺少直接证据时取 0.5 中性。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? canonicalBenchmark[model][domain] * providerQualityFactor\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 规范模型基准 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n#### 5.7.4 规范模型能力基准\n\n`shared/model-registry/ccx_model_registry.json` 的 `benchmarkProfiles` 独立保存规范模型能力上界,不与 `upstreamCapabilities` 混合。后者仍是 context、vision、tool calls 等硬能力的事实源;benchmark 只参与 Autopilot 软评分和 trace 解释,绝不改变 `supportedModels` 或 CapabilityFloor。\n\n```text\nEffectiveCapability[domain]\n = CanonicalCeiling[model, domain]\n × ProviderQualityFactor[endpoint]\n\nProviderQualityFactor = 1 - confidence × (1 - providerQualityScore)\n无渠道质量证据或 confidence<0.5 时 factor=1;可信证据只能从规范模型上界向下折算。\n```\n\n基准数据必须保留领域向量、来源、核验日期、lane、共享结果数和可比类别覆盖率,不能只保存站点 Overall。当前 BenchLM 接入为 `provisional`:Claude Opus 4.8、GPT-5.6 Terra、GPT-5.6 Sol 各有 30 个共享结果,8 类中 5 类可比,核验日期为 2026-07-13。\n\n领域映射:Knowledge → `general`,Math → `reasoning`,Coding → `coding` / `code_review`,Agentic → `agentic`,Multimodal → `aesthetics_ui`(仅弱代理,解释置信度减半)。Writing / Translation 没有直接类别证据时继续回退家族种子或 0.5。BenchLM 的 cohort 归一化、缺失项插补和外部校准决定了 Overall 不是绝对能力值,因此路由只消费可映射的原始类别分。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n#### 5.8.4 联合决策的现状实现(model + effort 原子化)\n\n> 状态说明(2026-07-26):本节描述的联合决策链路已实现并接入真实请求路径(关联计划 [2026-07-26-autopilot-model-effort-joint-routing.md](../superpowers/plans/2026-07-26-autopilot-model-effort-joint-routing.md) 的 Task 1~7)。**渠道级手工 `modelMapping` / `reasoningMapping` 表目前仍然存在且仍然生效**,退役是该计划的 Task 8,尚未执行;手工表当前的优先级关系见下文。\n\n**决策对象。** 自动路由的返回值不再是裸模型名,而是 `autopilot.ResolvedRouteTarget`(`backend-go/internal/autopilot/route_target.go`):\n\n```go\ntype ResolvedRouteTarget struct {\n Model string // 目标模型 ID\n Effort EffortLevel // 目标思考档位;空串表示不改写\n EffortDecided bool // true 表示 effort 由 Autopilot 决定;false 为 passthrough\n Reason string // 决策原因,用于 Trace 与日志\n}\n```\n\n`EffortDecided=false` 且 `Effort` 为空是合法的 fail-open 结果:模型可能被改写,但思考参数保持原样。\n\n**决策发生的位置。** `autopilot/endpoint_policy.go` 的 `resolveMappedModel()` 是统一入口,内部分两路:`resolveManualMapping()` 处理渠道级显式 `ModelMapping`(仍是当前优先级更高的一路,返回的 `ResolvedRouteTarget.EffortDecided` 恒为 `false`,即手工映射只改模型不改档位);`resolveAutoModel()` 调用 `ModelResolver` 做自动模型解析,并在 `model_resolver.go` 的 `rankEligibleModels()` 中按 `SupportedEffortLevels` × `PerTaskClass` 展开 `(model, effort)` 候选、应用 `EffortQualityBonus` 与 anti-effort-inflation 反膨胀 tiebreak(同质量档优先选低 effort)、并按 `EffortFloor` 过滤掉低于任务下界的候选。产出的 `ResolvedRouteTarget` 经 `buildResolvedTargetLookup()` 挂到 `EndpointAttemptPolicy.ResolvedTargetByEndpointUID`,与旧的 `ResolvedModelByEndpointUID`(仅模型,向后兼容)并列。\n\n**决策如何到达上游请求。** `handlers/common/upstream_failover.go` 的 `TryUpstreamWithAllKeys` 在为每个 endpoint 尝试构建请求前,通过 `ResolvedTargetByEndpointUID` 取得 `*ResolvedRouteTarget`,交给 `atomicModelEffortRewrite()` 做一次原子改写:先用 `sjson` 改写 `model` 字段;若 `EffortDecided=true` 且 `Effort` 非空,再检查目标模型的 `ThinkingMode`——`adaptive_only` 的模型跳过思考参数注入(模型自身决定思考深度,守卫逻辑对齐 `providers/claude.go` 的 `applyClaudeReasoningEffort`),否则调用 `config.ApplyReasoningParamStyle()` 写入。模型改写与档位改写共用一次函数调用,任一步 `sjson`/`json` 处理失败即整体放弃、保持原始 body,不会出现\"模型改了、档位没改\"的中间态。\n\n**各协议的注入形态。** `config.ApplyReasoningParamStyle(req, style, effort)`(`config/config_utils.go`)按 `upstream.ReasoningParamStyle` 三态分支:\n\n```text\nstyle=\"thinking\" → Claude 派系:写 thinking.type=enabled + thinking.effort=;\n effort 为 off/none 时写 thinking.type=disabled;同时清理互斥字段\n reasoning / reasoning_effort / thinking.budget_tokens\nstyle=\"reasoning_effort\" → OpenAI Chat 派系:写顶层 reasoning_effort 字段,清理 reasoning\nstyle=\"\" / 默认 → Responses 派系:写 reasoning.effort 对象形态\n```\n\n**Gemini 派系是尚未接通的缺口。** 请求侧提取(`extractGeminiEffortExplicit()`,`handlers/common/reasoning_log.go`)已经能读取 `generationConfig.thinkingConfig.thinkingLevel` 并识别显式 `none`(`thinkingBudget=0`),这条读路径用于日志与 `RequestProfile.ClientEffort` 提取。但写路径——把联合决策的 `target.Effort` 写回 Gemini 请求体的 `thinkingConfig.thinkingLevel`——目前代码中不存在:`atomicModelEffortRewrite()` 唯一的写出口是 `ApplyReasoningParamStyle()`,它只覆盖 Claude/Chat/Responses 三种形态,没有 Gemini 分支;`handlers/gemini/handler.go` 的 `buildProviderRequest()` 也没有调用任何 effort 注入函数。也就是说,Gemini 渠道当前仍会经过 endpoint 级模型改写(`atomicModelEffortRewrite` 会改写 `model` 字段本身),但联合决策选中的思考档位不会落到 Gemini 请求体上,效果等同于 fail-open passthrough。此项在原计划里归属 Task 5 的\"Gemini 派系注入路径\",尚未实现。\n\n**可观测性字段。** 原子改写点会向 `gin.Context` 写入两个字段供 ChannelLog 使用:`effortDecisionSource`(`autopilot` 表示由联合决策产出,`passthrough` 表示未改写或手工映射路径)、`effortClampedByClient`(当客户端显式声明的档位低于 Autopilot 选择的档位时置 `true`,由 `isEffortClampedByClient()` 基于 `EffortLevelOrdinal` 比较得出)。\n\n**手工映射表的现状。** `resolveMappedModel()` 当前的分支顺序是\"显式 `ModelMapping` 优先,自动决策兜底\"——与原计划 Task 7 描述的\"反转优先级\"目标相反的方向仍是默认值;反转开关已作为可配置项落地(提交 `d15e776e`),但默认状态未切换,`ModelMapping` / `ReasoningMapping` 两张表本身尚未删除,`config.ResolveReasoningEffort()`(`config/config_utils.go`)与 provider 层(如 `providers/claude.go`)仍会读取 `ReasoningMapping` 独立生效。表的整体退役是 Task 8 的范围,未开始。\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**请求意图边界(ModelRoutingPolicy)**\n\n跨模型替代不是所有模型的默认能力,只对白名单客户端入口开放:\n\n```text\nmessages:\n fable / opus / sonnet / haiku 及对应 claude-* 完整模型名 → adaptive\nresponses:\n gpt-5.6* / gpt-5.5 / gpt-5.4* / codex-auto-review → adaptive\n其他协议或模型:\n exact-only,只能在探测到相同规范化模型 ID 的 endpoint 之间调度\n```\n\n`adaptive` 允许在满足 Capability Floor 后选择 GLM、MiMo 等其他模型族;`exact-only` 禁止跨模型替代。用户显式配置的 `modelMapping` 仍优先于该策略。调度预览和 endpoint 实际解析必须调用同一分类函数,避免诊断结果与真实请求分叉。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ModelProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选优\n // 上下文窗口只负责准入,不对更大的窗口扣分。\n // 排序优先级:\n // a. 模型质量档越高越优先\n // b. provider 已确认的同档模型能力顺序\n // c. 带置信度折算的供应商实测质量\n // d. 探测延迟\n // e. 同渠道/provider 的模型相对消耗倍率\n // 倍率相同或缺失时再比较公开模型成本\n // f. 同模型族与 model ID 仅作为确定性兜底\n best := rankEligibleModels(eligible, requestModel)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n同渠道套餐倍率与公开 USD/Token 价格是不同单位,禁止直接混算。模型解析器先比较 provider\n提供的相对消耗倍率;只有双方倍率相同,或双方均无倍率时,才回退到统一输入/输出规模下的\n公开价格。优云智算当前内置倍率如下(数字越小越省,`glm-5.2` 的 2 次为限时值):\n\n| 模型 | 每次调用套餐消耗 |\n|------|-----------------:|\n| `glm-5.2` | 2 |\n| `glm-5.1` | 6 |\n| `MiniMax-M2.7` | 2 |\n| `kimi-k2.6` | 5 |\n| `deepseek-ai/DeepSeek-V3.2` | 1 |\n| `deepseek-v4-flash` | 1 |\n\nprovider 可以额外声明同一质量档内已确认的模型能力顺序,但只有该档全部候选都命中该元数据\n时才比较,避免把未知模型误判为弱模型,也保证排序不依赖候选输入顺序。优云模板当前记录\n`glm-5.1 > kimi-k2.6`,因此前者即使消耗 6 次,也会在后者的 5 次成本证据之前胜出。\n`glm-5.2` 即使消耗 2 次,也仍会在质量档高于 DeepSeek 候选时优先;V3.2 与 V4 Flash\n同为 1 次时,再由公开价格及最终确定性规则区分。静态 `modelMapping` 仅保留为用户显式覆盖,\n不承担自动替代策略。\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - autoManaged 的空 supportedModels 不表示支持全部,必须由 endpoint ModelProfile 裁决\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n当 ModelResolver 已正常初始化且路由处于 `assist/auto + autoResolve=true` 时,autoManaged 渠道的画像拒绝是权威结果,调度器不得再 fail-open 到空 `supportedModels` 的兼容语义。Resolver 不可用、开关关闭或 `off/shadow` 时仍保留原有 fail-open,保证急停和降级路径不依赖画像库。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\nGET /api/health-center/provider-quality/budget → 查看本进程手动 L3 每日预算\nPOST /api/health-center/provider-quality/probe → 对指定 endpoint×model 执行固定质量 canary\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n#### 8.0.1 实现现状与演进方向(2026-07-09 核实)\n\n四界面在 **Web 前端(`frontend/src`)已大面积落地**,不是纯文档:\n\n| 界面 | Web 实现 | Desktop 实现 |\n|------|----------|--------------|\n| 健康中心 | ✅ `HealthCenterView.vue` + `HealthCenterStats`/`HealthChannelTable`/`HealthChannelDetail`/`ProfileChangelogTimeline`,路由 `/health` | ❌ 无 |\n| 订阅中心 | ✅ `SubscriptionsView.vue` + `SubscriptionPlanTable`,CRUD + 刷新余额,路由 `/subscriptions` | ⚠️ 独立旧实现 `subscriptions/SubscriptionTab.vue`(与 Web 两套代码) |\n| 渠道中心 | ✅ `ChannelOrchestration.vue`(六态 `ChannelHealthBadge`、来源 chips、`autoManaged` 图标齐备) | ❌ 未同步 |\n| 管理面板 | ⚠️ `AutopilotModePanel.vue` 仅暴露 mode/killSwitch/costPreference | ❌ 无 |\n| 驾驶舱 | ✅ `CockpitView.vue`(观测型总览:健康统计+余额汇总+渠道推荐) | ⚠️ `cockpit` tab 渲染的是会话 Dashboard,职责不同 |\n\n**与本设计意图的关系**(对齐用户「自动调度为主、废旧手动驾驶舱」的诉求):\n- **旧的「手动调度驾驶舱」在 Web 端已不存在**。`CockpitView` 已是 §8.7 定义的观测型总览,只回答健康/省钱/风险/待办四问题,不做渠道配置编辑,符合「程序自动选、人只观测+有限干预」的方向。无需再废除,只需继续收敛为纯观测。\n- **统一提交入口已存在**(§8.4 快速添加 + §8.5 订阅中心),new-api 集成(§8.5.1)在此之上把「填 baseURL+key」进一步自动化。\n\n**前端演进待办**(本设计新增/明确):\n1. **补 `ManualRoutingIntent` 前端**:这是用户要的「有限偏向性干预」,后端已有、两个前端均未实现(§8.3 试用意图入口尚缺)。\n2. **Desktop 前端对齐 Web**:健康中心/渠道自动托管/管理面板/观测型驾驶舱在 Desktop 几乎空白,需同步或复用 Web 组件,避免长期维护两套。\n3. **管理面板补字段**:暴露 `l2ProbeEnabled`、探测预算、new-api/多来源订阅 adapter 开关(§8.5.2)等,当前只有 3 个字段。\n4. **订阅中心接 §8.5.1/§8.5.2**:新增「new-api」订阅类型与 Provider Adapter 能力矩阵驱动的动态表单。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.5.1 new-api 订阅集成(统一账号接入)\n\n**目标**:用户在订阅中心手填一个 new-api(及其 fork)账号的「系统访问令牌」即可,CCX 自动用该令牌创建代理专用 key、拉取分组倍率、查询额度余额,并把「站点 baseURL + 新建 key」自动落成一个上游渠道,交给 Autopilot 探测与调度。\n\n**范围约束**:\n- 只使用 new-api 用户在「个人设置」生成的 **系统访问令牌(access_token)** 一种凭据;令牌用于 API 调用的身份验证,属敏感数据,须妥善加密存储。\n- **不做** cookie 登录 + 2FA + 导出后台渠道明文 key 的管理员迁移路径(all-api-hub 的 newApiSession 能力),本设计不涉及。\n- new-api 归入 `OriginType=relay`、`OriginTier=second`(中转站),信任等级不因自动化提升;实时质量仍由运行画像决定。\n- 认证令牌像其他订阅凭据一样由用户手动录入,CCX 不代替用户在 new-api 侧注册或登录。\n\n**认证与凭据模型**:\n\nnew-api family 的管理接口鉴权(依据 all-api-hub 传输层实现):\n\n```text\nAuthorization: Bearer # 系统访问令牌;raw 模式可去掉 Bearer 前缀\nNew-API-User: # 当前用户 ID(必带)\nUser-id: # fork 兼容回退(Veloera/voapi/Super-API 等)\nContent-Type: application/json\n```\n\n统一响应信封:`{ \"success\": bool, \"data\": T, \"message\": string }`;解析时先判 `success`,再取 `data`。\n\n**SubscriptionProfile 扩展**:\n\n在 §3.2.2 的 `SubscriptionProfile` 基础上,为可自动接入的订阅新增 provider 集成字段(沿用「默认关闭 + 显式 opt-in」守则):\n\n```go\n// SubscriptionProfile 新增字段(复用 Phase 4 Item 6 的 SubscriptionAutoRefresh 开关)\ntype SubscriptionIntegration struct {\n Kind string `json:\"kind\"` // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | ...\n BaseURL string `json:\"baseUrl\"` // new-api 站点地址(同时作为新建渠道的上游 baseURL)\n AccessToken string `json:\"-\"` // 系统访问令牌;加密存储,不出 API 响应\n UserID string `json:\"userId\"` // New-API-User 头所需\n AuthTokenMode string `json:\"authTokenMode,omitempty\"` // \"bearer\"(默认) | \"raw\"\n // 自动建 key 的模板\n ProvisionKeyName string `json:\"provisionKeyName,omitempty\"` // 默认 \"ccx-autopilot\"\n ProvisionGroup string `json:\"provisionGroup,omitempty\"` // 建 key 时指定分组,空=默认分组\n ProvisionModels []string `json:\"provisionModels,omitempty\"` // model_limits,空=不限制\n // 自动同步产物(回填,只读展示)\n ProvisionedTokenID int `json:\"provisionedTokenId,omitempty\"`\n LinkedChannelUID string `json:\"linkedChannelUid,omitempty\"`\n}\n```\n\n`AccessToken` 必须与现有敏感字段一样走加密存储,序列化到前端时脱敏(仅显示尾部若干位)。\n\n**new-api 接口清单**(本集成实际调用的子集,路径以主线 new-api 为准,字段名保持与上游一致):\n\n| 用途 | 方法 | 路径 | 请求体 | 响应关键字段 |\n|------|------|------|--------|--------------|\n| 校验令牌 + 查用户/余额 | GET | `/api/user/self` | — | `data.id`、`data.username`、`data.quota`(剩余额度)、`data.used_quota` |\n| 拉分组倍率 | GET | `/api/user/self/groups` | — | `data: { : { desc, ratio } }` |\n| 站点全部分组 | GET | `/api/group` | — | `data: string[]` |\n| 账号可用模型 | GET | `/api/user/models` | — | `data: string[]` |\n| 创建代理 key | POST | `/api/token/` | `CreateTokenRequest`(见下) | `data.key` / `success` |\n| key 列表 | GET | `/api/token/?p={p}&size={n}` | — | `data.items[]`(含 `id/key/name/status/remain_quota`) |\n| 更新 key | PUT | `/api/token/` | `{ ...token, id }` | `success` |\n| 删除 key | DELETE | `/api/token/{id}` | — | `success` |\n\n`CreateTokenRequest`:\n\n```json\n{\n \"name\": \"ccx-autopilot\",\n \"remain_quota\": 0,\n \"expired_time\": -1,\n \"unlimited_quota\": true,\n \"model_limits_enabled\": false,\n \"model_limits\": \"\",\n \"allow_ips\": \"\",\n \"group\": \"\"\n}\n```\n\n`quota` 是 new-api 内部额度单位(非美元),换算比例读 `GET /api/status` 的 `price` / `PaymentUSDRate`(无鉴权),或允许用户在订阅中手填换算率;MVP 直接以 quota 原值展示 + 站点换算率估算余额。\n\n**接入流程**(订阅中心「新增订阅 → 类型选 new-api」):\n\n```text\n1. 用户输入:站点 baseURL + 系统访问令牌(access_token) + userId\n (userId 可选自动获取:先用令牌调 /api/user/self 拿 data.id 回填)\n2. 校验:GET /api/user/self\n ├─ success=false / 401 → 令牌无效或 userId 不匹配,报错停止\n └─ 成功 → 记录 username、quota,创建 SubscriptionProfile(provider=new_api, originTier=second)\n3. 拉分组:GET /api/user/self/groups → 写入 GroupMultipliers({group: ratio})\n GET /api/user/models → 记录账号可用模型,供渠道 supportedModels 参考\n4. 建 key:POST /api/token/(按 ProvisionKeyName/Group/Models 模板)\n ├─ 已存在同名 ccx-autopilot key → 复用其 id,不重复创建(列表比对 name)\n └─ 新建成功 → 回填 ProvisionedTokenID,取 data.key 作为渠道 apiKey\n5. 自动建渠道:用 baseURL + 新建 key 创建一个上游渠道\n ├─ channelKind 由用户选择或按站点探测(messages/chat/responses/gemini)\n ├─ status=unknown,autoManaged=true,OriginType=relay\n ├─ 建立 SubscriptionUID → ChannelUID 链接(回填 LinkedChannelUID)\n └─ 触发 §8.4 的 Discovery + 能力测试标准流程\n6. 完成:订阅卡片显示余额、分组倍率、已绑定渠道、探测进度\n```\n\n关键点:new-api 集成**不额外造探测逻辑**,第 5 步之后完全复用现有 §8.4「添加并探测」链路;集成层只负责「令牌 → key + 分组 + 余额 + baseURL」的自动填充,把手填 baseURL/key 的动作自动化掉。\n\n**刷新与同步**(复用 `POST /api/subscriptions/{uid}/refresh`):\n\n| 触发 | 动作 |\n|------|------|\n| 手动刷新余额 | `GET /api/user/self` 更新 `Balance` + `BalanceUpdatedAt` |\n| 定时刷新(可选,opt-in) | 复用 Phase 4 Item 6 `SubscriptionAutoRefresh`;new-api 作为 `relay` provider adapter 纳入,默认关闭 |\n| 分组倍率变更 | 刷新时对比 `/api/user/self/groups`,`GroupMultipliers` 变化 → 更新并提示「套餐倍率已变」 |\n| key 失效检测 | 渠道侧探测到该 key 全 401 → 订阅卡片提示「key 可能已删除/禁用」,提供「重新建 key」一键动作(重跑第 4-5 步,复用旧渠道,只换 apiKey) |\n| 可用模型变更 | `/api/user/models` 结果哈希变化 → 触发对应渠道重新 Discovery |\n\n**安全与边界**:\n- `access_token` 权限等同 new-api 账户,须加密存储、脱敏展示、禁止写入日志;刷新失败连续 N 次自动停用该订阅的自动刷新,只保留手动。\n- 自动建 key 默认 `unlimited_quota=true` 但可由用户改为限额模板;建 key 是有副作用的写操作,须在接入前明确告知用户「将在 new-api 侧创建一个名为 ccx-autopilot 的令牌」。\n- new-api 是 `OriginTier=second`,**不得**用作隐私敏感的 routing advisor / classifier(遵守 §3.2.1 约束)。\n- fork 兼容:请求同时带 `New-API-User` 与 `User-id` 头;响应信封字段名(`success/data/message`)在部分 fork 可能不同,adapter 需容错解析,失败时回退手动录入。\n- 一个 new-api 订阅默认只自动建/绑定一个渠道;用户如需该站多协议渠道,可在同订阅下手动增建,共用同一 key。\n\n### 8.5.2 订阅 Provider Adapter 框架(多来源余额与接入)\n\n§8.5.1 的 new-api 集成不是特例,而是「订阅 Provider Adapter」框架的一个实现。Phase 4 Item 6 已落地 openai/anthropic/google 的余额刷新 adapter,本框架把它抽象成统一接口,让 new-api 及后续来源(GitHub Copilot、Codex、Nvidia、MiniMax、DeepSeek 等)按相同契约接入,避免每加一个来源就散落一套逻辑(DRY)。\n\n**统一接口**:\n\n```go\n// backend-go/internal/autopilot/subscription/adapter.go\ntype ProviderAdapter interface {\n Kind() string // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | \"copilot\" | \"deepseek\" | ...\n\n // 校验凭据,返回账号身份(用户名/账户标识);失败即凭据无效\n Verify(ctx context.Context, cred Credential) (AccountInfo, error)\n // 查余额/额度;不支持返回 ErrUnsupported,由 UI 降级为手动录入\n FetchBalance(ctx context.Context, cred Credential) (Balance, error)\n // 拉分组倍率;仅 new-api 类中转有,官方直连返回 ErrUnsupported\n FetchGroups(ctx context.Context, cred Credential) (map[string]GroupRatio, error)\n // 自动创建代理 key;官方直连通常不支持(key 即用户 API key),返回 ErrUnsupported\n ProvisionKey(ctx context.Context, cred Credential, tmpl KeyTemplate) (ProvisionedKey, error)\n // 可用模型列表;用于渠道 supportedModels 参考\n FetchModels(ctx context.Context, cred Credential) ([]string, error)\n}\n```\n\n每个能力独立、可选:adapter 只实现自己支持的部分,不支持的统一返回 `ErrUnsupported`(接口隔离原则),UI 据此隐藏/降级对应入口。所有 adapter 共用 `SubscriptionAutoRefresh` 的 opt-in 开关与 `Validate()` 兜底,与 Phase 4 现有防御模式一致。\n\n**能力矩阵**(✅ 支持 / ⚠️ 部分或需确认 / ❌ 不支持,走手动):\n\n| 来源 | Kind | 验证 | 查余额 | 拉分组 | 自动建 key | OriginTier |\n|------|------|:----:|:------:|:------:|:----------:|------------|\n| new-api family | `new_api` | ✅ | ✅ quota | ✅ | ✅ | second |\n| OpenAI 官方 | `openai` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Anthropic 官方 | `anthropic` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Google 官方 | `google` | ✅ | ⚠️ | ❌ | ❌ | first |\n| GitHub Copilot | `copilot` | ⚠️ token | ❌ 无额度概念 | ❌ | ❌ | first |\n| Codex plan | `codex` | ⚠️ | ⚠️ 用量窗口 | ❌ | ❌ | first |\n| Nvidia NIM | `nvidia` | ✅ | ⚠️ credits | ❌ | ❌ | first |\n| MiniMax | `minimax` | ✅ | ⚠️ | ❌ | ❌ | first |\n| DeepSeek | `deepseek` | ✅ | ✅ balance API | ❌ | ❌ | first |\n\n说明:官方直连来源普遍**不支持自动建 key**(用户的 API key 本身就是凭据,直接作为渠道 apiKey 录入即可)、也**没有分组倍率**(价格按官方定价表)。它们的 adapter 价值集中在「验证 + 查余额/用量窗口」,把 §3.2.4 UsageWindow 的官方用量 API 来源坐实。DeepSeek 有独立余额查询端点,可完整支持余额。Copilot 属订阅制、无 token 余额概念,只做验证 + 用量提示。\n\n**接入分层**(YAGNI:不强求一次做全,按价值排期):\n\n```text\nP0(本轮):new_api(完整:验证+余额+分组+建key+自动建渠道)\n —— 覆盖用户最主要的中转站场景\nP1:deepseek(验证+余额)、官方三家余额已在 Phase 4 完成 → 补 UsageWindow 对齐\nP2:nvidia / minimax(验证+credits/余额)\nP3:copilot / codex(验证+用量窗口提示,无余额)\n```\n\n每个来源的具体端点在实现时于 `adapter.go` 各 provider 文件内落地,本设计只固定接口契约与能力矩阵,避免文档与上游 API 变动强耦合。新增来源 = 实现 `ProviderAdapter` + 在能力矩阵登记,不改框架。\n\n**能力缺失与降级**:\n- adapter 任一能力返回 `ErrUnsupported` → 订阅中心对应字段回退为手动录入(余额手填、倍率手填),不报错、不阻塞接入。\n- adapter 验证失败/超时 → 订阅可仍以「手动模式」创建,仅失去自动刷新与自动建 key,其余流程(手填 baseURL+key 建渠道)不受影响。\n- 官方来源无「自动建 key」时,接入流程退化为 §8.4 标准快速添加:用户填 API key,adapter 只补验证与余额。\n- 所有自动刷新遵守 `SubscriptionAutoRefresh` 默认关闭;连续失败自动停用自动刷新并在驾驶舱「人工待办」提示。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3,\n \"providerTimePricing\": {\n \"deepseek\": {\n \"effectiveFrom\": \"2026-07-20T00:00:00+08:00\",\n \"timeZone\": \"Asia/Shanghai\",\n \"peakMultiplier\": 2,\n \"peakWindows\": [\n { \"start\": \"09:00\", \"end\": \"12:00\" },\n { \"start\": \"14:00\", \"end\": \"18:00\" }\n ]\n }\n }\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n`providerTimePricing.effectiveFrom` 使用 RFC3339;修改该值即可调整激活时间,设为空字符串则停用对应规则。\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**Phase 3A 状态(2026-07-09)**:低风险、不改真实路由语义的子集已完成并接入真实链路——`Manager.collectAll()` 新旧画像对比产出变更事件(`DetectProfileChanges`)、`ProfileChangelogStore`(环形内存 + SQLite,30 天机会性清理)、`EventHub` 内存 pub/sub、`GET /api/health-center/changelog`(历史)+ `GET /api/health-center/events`(WebSocket 实时推送)、`AutoDiscoveryRunner` 发布 `discovery_completed`/`auto_mapping_applied`、前端 `ProfileChangelogTimeline.vue` 接入健康中心页并支持断线自动重连。均为只读展示,未修改 `SmartRouter`/`EndpointAttemptPolicy` 调度逻辑。落地时顺带发现并修复一个真实缺口:浏览器原生 WebSocket 无法设自定义 header,为此给 `middleware.getAPIKey` 增加了 `Sec-WebSocket-Protocol` 鉴权回退(业界标准做法,仅新增一个 key 来源,不降低现有校验)。\n\n**Phase 3B(已全部完成,2026-07-09)**:模型自动映射、自动恢复探测、晋升/降级机制会直接改变真实调度决策,风险明显高于 3A,四个子项已分批全部完成。§12.2 P1.5 的 SQLite schema version / 版本化 migration / `OriginType`&`OriginTier` 旧配置 backfill / `killSwitch`+按 task class/channel disable **已于 2026-07-09 补强完成**(详见 §12.2 P1.5)。**Phase 3B-1(自动恢复探测补全)已于 2026-07-09 完成**:修复了 L2 探测子系统里此前从未打通的两个真实 bug——① `collectAll()` 每轮把新构造的画像整行 Upsert,会把 `ProbeWorker` 刚写入的 `LastProbeAt`/`ProbeSuccess`/`ConsecutiveProbeSuccess` 等字段清零,导致 6 小时探测冷却期形同虚设(修复:`carryForwardProbeFields` 在 Upsert 前搬运 Probe* 字段,不影响 L1 诊断字段的现有覆盖逻辑);② `extractRawAPIKey` 是返回 `KeyMask` 掩码值的占位 stub,探测请求实际带的是假 key(修复:新增 `APIKeyResolver` 回调 + `Manager.ResolveAPIKey`,通过遍历配置里对应渠道的 `APIKeys` 逐个计算 `KeyHashFromAPIKey` 比对来还原明文;resolver 未注入或未命中时 fail-open 跳过探测且不消耗预算)。同时补齐了 `degraded/limited → healthy` 的连续成功恢复路径(新增 `KeyEndpointProfile.ConsecutiveProbeSuccess` 计数器 + `HealthCheckConfig.ProbeRecoveryThreshold`,默认连续 2 次探测成功才恢复,避免单次探测噪声导致状态抖动;探测失败计数器清零)。**Phase 3B-3(StabilityTier 晋降级滞后)已于 2026-07-09 完成**(提交 664d3fff):在原本每轮从瞬时 1h 窗口重算的无记忆 `StabilityTier` 上加一层连续窗口滞后——新增 `KeyEndpointProfile.EffectiveStabilityTier`/`StabilityPendingTier`/`StabilityPendingStreak` 三字段 + 纯函数 `applyStabilityHysteresis`(对称阈值:连续 N 轮同值才切换生效档,`HealthCheckConfig.StabilityHysteresisWindows` 默认 2;首次画像直接采用,不等待阈值,避免新渠道卡在零值),`AggregateChannelProfile` 改读 `EffectiveStabilityTier`(空则回退 `StabilityTier`)。晋升/降级机制的数据模型仅限于 StabilityTier,不改变 QualityTier/SpeedTier/CostTier 语义。**Phase 3B-2(模型自动映射闭环)已于 2026-07-09 完成**(提交 0f02e988,随后修复一处 critical 集成 bug,提交 856372c4):新建 `ModelProfileStore`(SQLite,`model_profiles` 表)+ 扩展 `AutoDiscoveryRunner` 为每个发现模型写入 `ModelProfile`(仅 `AutoManaged` 渠道);新建 `ModelResolver` 实现 §5.4 五步逻辑(手动 `RedirectModel` 短路 → `GetModelProfiles` → `filterByCapabilityFloor` → `rankBySimilarity`);`scheduler` 包新增 `ModelSupportResolverFunc` hook(fail-open,未注入或返回不支持时严格回退到原 `ExplainModelSupport`);`endpoint_policy.go` 的 `resolveMappedModel`(此前是永不触发的死代码——`profile.ModelMapping` 从未被写入)现调用 `ModelResolver`;`handlers/common` 在 `RedirectModel` 之后应用 `MappedModel` 覆盖请求体,并按 `EchoMappedModel` 配置回显 `X-CCX-Mapped-Model`/`X-CCX-Original-Model`/`X-CCX-Mapping-Source` 响应头。三条件安全门控:`ModelMapping.AutoResolve==true`(**默认由死配置的 true 改为 false**)AND `RoutingMode ∈ {assist, auto}` AND 无 `KillSwitch`,三者任一不满足则所有新增代码路径行为与今天字节级一致;且仅 `AutoManaged` 渠道触发,手动配置渠道完全不受影响。对抗性复核发现并修复一个真实 critical bug:`scoreEndpointForKey` 用空 `channelUID` 计算 `EndpointUID` 并写入 `modelByUID`,而 `handlers` 层用真实 `channelUID` 查 `modelByUID`,两者哈希永不相等导致 `MappedModel` 在 key 级调度路径上完全查不到(提交 856372c4 修复:命中画像后改用 `profile.EndpointUID`,新增回归测试锁定该不变量)。至此 Phase 3B 四个子项(自动恢复探测、StabilityTier 滞后、模型自动映射、active filter 前置判定)全部完成;SLO regression 自动回滚仍是独立子系统,留待后续单独立项。\n\n**范围**:\n- [x] 运行时指标驱动画像实时更新(Phase 3A:`collectAll()` 每轮 diff + changelog + WebSocket 推送)\n- [x] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)—— Phase 3B-2(2026-07-09 完成,提交 0f02e988 + 856372c4)\n- [x] active model filter / context filter 支持自动映射前置判定 —— Phase 3B-2:`scheduler.ModelSupportResolverFunc` fail-open 接入 `select.go`\n- [x] 自动恢复探测(limited/dead → healthy)—— Phase 3B-1(2026-07-09 完成):`APIKeyResolver` + `carryForwardProbeFields` + `ConsecutiveProbeSuccess`/`ProbeRecoveryThreshold`\n- [x] 晋升/降级机制(连续成功→升级,连续失败→降级)—— Phase 3B-3(2026-07-09 完成,提交 664d3fff):`StabilityTier` 连续窗口滞后(`EffectiveStabilityTier`),暂不含 QualityTier/SpeedTier/CostTier\n- [x] WebSocket 推送画像变更事件(Phase 3A:`GET /api/health-center/events`)\n- [x] 前端画像变更历史/时间线(Phase 3A:`ProfileChangelogTimeline.vue`)\n\n**预估工期**:2-3 周(3A、3B-1、3B-2、3B-3 均已完成;advisor 与全局 auto 的 SLO 回滚均已补齐)\n\n> 状态校正(2026-07-17):上方 Phase 3B 历史说明中的“SLO regression 自动回滚待单独立项”已完成;advisor 回滚与全局 auto 上线闸门/自动降级是两个独立保护层。\n\n### Phase 4:高级特性\n\n**Phase 4 状态(2026-07-09,全部完成)**:8 项全部实现并接入真实链路,均遵循\"默认关闭 + 显式 opt-in\"安全守则。\n\n**范围**:\n- [x] 多维度标签系统扩展(用户自定义标签)——Item 1\n- [x] 成本报表:按用户/模型/渠道/key 统计真实有效成本——Item 2(新增 `proxyKeyMask` 身份贯穿链路:`middleware.GetProxyKeyMask` → `ChannelLog`/`RequestRecord` → SQLite `proxy_key_mask` 列,供 Item 4 复用)\n- [x] Trusted advisor SLO regression 自动回滚——Item 3(默认关闭;`SLORollbackConfig`,连续 degrading 窗口数默认 3,不做自动恢复以避免震荡)\n- [x] 渠道推荐:根据使用模式推荐新渠道——Item 4\n- [x] 批量渠道管理(导入/导出/模板)——Item 5\n- [x] 订阅中心 provider adapter:可选自动刷新余额/套餐状态——Item 6(默认关闭;仅 openai/anthropic/google 且显式填写 BillingAPIKey 的订阅生效,中转/公益渠道继续手动维护)\n- [x] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板——Item 7\n- [x] A/B 测试:低比例统计抽样双发——Item 8(默认关闭,默认 1% 抽样 + 每小时 60 次硬预算;主请求路径不受影响,影子请求在主响应返回后异步发起)\n\n**关键实现说明**:\n- Item 3/6/8 均在 `AutopilotRoutingConfig` 新增独立配置块(`SLORollback`/`SubscriptionAutoRefresh`/`ABTest`),`Validate()` 统一做负值/越界兜底,保持与既有字段一致的防御模式。\n- Item 4 直接复用 Item 2 的 `proxyKeyMask` 身份标记,未重新发明身份识别机制。\n- **全局 auto 安全闸门(2026-07-17)**:请求终态按 15 分钟窗口无偏聚合,详细 trace 继续抽样;管理 API 只在 shadow/assist 已积累至少 500 个有效请求、24 小时连续观测且成功率/fallback/fail-open/p95 均达标时允许切换 `auto`。后台连续检测三个已完成 auto 窗口,相对最近七天安全模式基线持续恶化时,持久化降级到 `assist` 并记录 `autopilot_auto_safety_events`,不自动恢复。\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- ~~SQLite schema version 和幂等 migration。~~ **已完成**:`ensureSchemaVersion` 基于 `PRAGMA user_version`,当前 7 张表定版为 v1 基线;库版本高于代码版本时返回 error(fail-closed,不做任何写操作),交给下游 fail-open 分支接管。\n- ~~旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。~~ **已完成**:`ensureOriginBackfill` 只补 `\"unknown\"`,不做任何基于 URL/名称的猜测推断;已接线到 `Profiler.DeriveEndpointProfile`,`BreakTieByOriginTier` 现在能吃到真实数据(此前一直是 fallback 的 `unknown`)。\n- ~~profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。~~ **已确认满足并测试锁定**:`main.go` 现有结构本就是 fail-open(`NewProfileStore`/`NewManager` 出错只打日志、`autopilotManager` 保持 `nil`,下游全部有 `nil` 守卫),本次新增 `schema_migration_test.go` 用测试锁定这条不变量。\n- ~~全局 `killSwitch`、按 task class disable、按 channel disable。~~ **已完成**:新增 `AutopilotRoutingConfig.DisabledTaskClasses`/`DisabledChannelUIDs`;`CandidateFilterFor`/`executeFilter`/`BuildPlan` 三处一致接入,`smart_router_invariant_test.go` 覆盖\"task class 禁用后回退默认调度\"\"channel 禁用后不出现在候选里\"\"dry-run 与真实路径行为一致\"三条不变量。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。**部分完成**:`ReleaseController.EvaluateAndApplyRegression` 已实现三窗口回归检测和 `SafetyOverride` 降级机制;`RoutingModeActive` 已加入配置枚举和 `IsAutopilotActive`。\n\n#### P1.6 Trace v2 契约与灰度发布\n\n> 对应实施计划:`docs/superpowers/plans/2026-07-23-autopilot-trace-rollout.md`\n\n**Trace v2 核心变更:**\n\n- `RoutingDecisionTrace` 增加 `SchemaVersion`、`TraceRevision`、`RequestCorrelationId`、`ReleaseID`、`PolicyFingerprint`、`TargetMode`/`EffectiveMode`/`Cohort`、`SchedulerDecision`、`EndpointAttempts` 字段。\n- 新增 `TraceDetailV2`、`TraceSummary`、`SchedulerDecisionSummary`、`EndpointAttemptSummary` DTO;使用显式 DTO 落盘而非直接 JSON 化运行时结构。\n- `ComparisonStatus` 三态枚举(`matched`/`mismatched`/`uncompared`)替代裸 `bool`。\n- `SanitizeForPersistence` 与 `SanitizeForResponse` 双边界脱敏;`GenerateTraceUIDv2` 使用 `crypto/rand` 碰撞安全生成。\n- `RoutingReleaseSnapshot` 不可变发布快照,入口冻结后续只读。\n\n**SQLite v6 迁移:**\n\n- trace 表新增列:`schema_version`、`trace_revision`、`request_correlation_id`、`release_id`、`policy_fingerprint`、`persistence_class`、`details_json`。\n- 窗口表重建主键:增加 `release_id`/`policy_fingerprint`/`cohort` 维度,新增 `compared_count`/`matched_count`/`mismatch_count`/`uncompared_count` 全量比较计数。\n- safety event 表增加 `release_id`/`policy_fingerprint` 字段。\n\n**灰度发布状态机:**\n\n```text\noff --管理员启用--> shadow --门槛满足--> assist --门槛满足--> auto --100% 稳定--> active\n```\n\n| 状态 | 行为 | 放量规则 |\n|---|---|---|\n| `off` | 不调用 Autopilot | rolloutPercent=0 |\n| `shadow` | 计算+记录,不影响调度 | rolloutPercent=0,安全基线 |\n| `assist` | 重排候选,不删除 | rolloutPercent 1→100 |\n| `auto` | 硬约束过滤+重排 | rolloutPercent 1→100 |\n| `active` | 与 auto 语义相同 | rolloutPercent=100,1% shadow control |\n\n**ReleaseController**(`release_controller.go`):\n\n- 集中处理相邻状态迁移校验(逐级晋升,降级随时允许)。\n- `ComputeCohort` 使用 session ID / request correlation ID 与 `rolloutSeed` 做稳定哈希分桶。\n- `EvaluateAndApplyRegression` 检查三窗口回归并应用 `SafetyOverride`。\n- `AllowedTransition` 禁止跳级,`modeRank` 确定顺序。\n\n**只读 Trace API:**\n\n| 接口 | 返回 | 约束 |\n|---|---|---|\n| `GET /api/autopilot/traces` | `TraceSummary[]` | 游标分页、release/cohort/mode 过滤、partial 标记 |\n| `GET /api/autopilot/traces/:traceUid` | `TraceDetailV2` | 404=未找到/已过期/未采样;503=存储不可用 |\n| `GET /api/autopilot/traces/stats` | `TraceStatsResponse` | 三态比较统计、Mode/TaskClass 分布 |\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | shadow 样本/准确率/critical misroute 门槛;active 后 SLO regression 自动 rollback;全局 kill switch |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "c11cf6d131c8a87baf10334f5cfb6d0c02ec644c": {"t": 1785759342.8524292, "n": 160781, "h": "26c4ead99bd29474", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.2.3 同订阅多 Key:能力共享与配额负载均衡\n\n**问题**:官方订阅 plan(Claude Max、Codex plan 等)允许用户持有多个 key,或购买多份相同套餐。若每个 endpoint 独立探测与配置,探测预算被浪费、配置重复;请求量大时还需要在同 plan 的多个 key 之间按官方 RPM 限速自动负载均衡,而不是打满第一个 key 才溢出到下一个。\n\n**能力检测去重(同一套餐只需一套能力检测和配置)**:\n\n```text\nSubscriptionUID 相同的 endpoint 共享一份「订阅级能力画像」:\n 模型列表 / vision / tool / reasoning 支持 / 协议兼容开关 → 只探测一次,\n 存在 SubscriptionProfile.SharedCapability,endpoint 画像以引用方式继承。\n\n仍然 per-endpoint 维护的:健康状态、延迟、熔断、用量窗口\n (key 可能被单独封禁/限速,能力相同不代表运行态相同)\n\n继承失效条件:某 key 实测模型列表哈希与订阅级不一致(GroupChangeDetector 信号)\n → 该 key 脱离继承、独立探测,并在 UI 标记「与套餐能力不一致」警告。\n```\n\n**配额负载均衡(Phase 1 shadow 展示,Phase 2 生效)**:\n\n```text\n每 key 维护配额余量视图:\n remainingRPM = officialRPM − 当前窗口已用\n officialRPM 来源优先级:内置 manifest > 用户配置 > RateLimitDiscoverer 建议\n\n同订阅多 key 时按「剩余余量加权」分配请求(余量大者优先、\n窗口最早重置者兜底),替代顺序打满:\n 仅当渠道 autoManaged 且同一 SubscriptionUID 下 key 数 > 1 时启用;\n 否则保持现有 sequential/random key 顺序策略不变。\n\n某 key 的窗口配额耗尽 → 临时降权并切到同订阅其他 key,\n不熔断整个渠道(这是配额耗尽,不是故障)。\n```\n\n### 3.2.4 订阅用量窗口 (Usage Windows)\n\n部分官方 plan 提供 5h / 日 / 周 / 月等窗口的用量查询与限制(如 Claude 订阅的 5h 滚动窗口 + 周配额)。在 key 的位置展示这些窗口,帮助用户掌握使用情况:\n\n```go\ntype UsageWindow struct {\n Window string `json:\"window\"` // \"5h\" | \"day\" | \"week\" | \"month\"\n Used float64 `json:\"used\"` // 已用量\n Limit float64 `json:\"limit\"` // 上限,0 = 未知\n Unit string `json:\"unit\"` // requests | tokens | credits | percent\n ResetsAt time.Time `json:\"resetsAt\"` // 窗口重置时间\n Source string `json:\"source\"` // official_api | response_header | local_metering\n FetchedAt time.Time `json:\"fetchedAt\"`\n}\n// KeyEndpointProfile 与 SubscriptionProfile 均增加 UsageWindows []UsageWindow\n```\n\n数据来源优先级:\n\n```text\n1. 官方用量 API:有 provider adapter 的 plan 主动拉取(§Phase 4 订阅 adapter 的可提前子集)\n2. 响应头:anthropic-ratelimit-* / x-ratelimit-* 等(复用 RateLimitDiscoverer 的信号 tap)\n3. 本地计量兜底:CCX 按 key 累计请求数/token(误差来自窗口起点对齐,标注 Source=local_metering)\n```\n\nUI 要求:key 行内显示主窗口(5h)用量进度条,悬浮展开全部窗口;用量 >80% 黄色预警、超限红色并显示预计重置时间。调度联动(Phase 2):窗口余量进入配额负载均衡权重,窗口耗尽的 key 临时降权而非熔断。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n#### 4.2.1 内置模型清单 (Builtin Models Manifest)\n\n**问题**:部分官方订阅 plan 的入口没有正确的 models 接口——要么不存在(404)、要么需要额外权限、要么返回与订阅实际可用模型不符的列表(典型:Claude 订阅 OAuth 入口、Codex plan 入口、部分官方企业网关)。对这些 baseURL 做模型发现会失败或产出错误画像。\n\n**方案**:CCX 内置「已知官方 baseURL → 模型清单」映射表,命中时直接返回内置内容,完全跳过上游 models 接口:\n\n```go\n// backend-go/internal/config/builtin_models_manifest.go(生成式,随模型注册表更新)\ntype BuiltinModelsManifest struct {\n BaseURLPattern string `json:\"baseUrlPattern\"` // 精确 host 或前缀匹配,如 \"api.anthropic.com\"\n ServiceType string `json:\"serviceType\"` // messages/responses/…\n PlanHint string `json:\"planHint,omitempty\"` // 订阅类型提示(如 \"claude_subscription\")\n ModelIDs []string `json:\"modelIds\"` // 该入口实际可用的模型清单\n DisableProbe bool `json:\"disableProbe\"` // true 时 Discovery 跳过 GET /v1/models\n}\n```\n\n**两个生效位置**:\n\n```text\n1. Discovery(§4.2 流程第 2 步之前):\n baseURL 命中 manifest 且 DisableProbe=true\n → 跳过 GET /v1/models,直接用 ModelIDs 生成 ModelProfile[]\n → ProfileSource = \"builtin_manifest\",不再因 models 接口失败而误判 misconfigured\n\n2. /v1/models 代理响应(现有 Models API):\n 客户端向 CCX 请求 /v1/models 时,聚合结果中该渠道的贡献\n 直接取内置清单,不向上游发起 models 请求\n (渠道显式配置了 supportedModels 时仍以用户配置优先)\n```\n\n**维护与覆盖顺序**:\n\n```text\n渠道显式 supportedModels(用户手动) > 内置 manifest > 上游 models 接口实探 > 内置候选模型回退\n```\n\nmanifest 与模型注册表同源生成(`generated_model_registry.go` 同级),新模型发布时随注册表一起更新;HealthAnalyzer 对命中 manifest 的 endpoint 不将「models 接口 404/403」计入 misconfigured 信号。\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20;assist 下 dead/misconfigured 额外 -100 并保留在队尾\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法细分时进入 `general`;有规范 Knowledge 基准则使用其软评分,缺少直接证据时仍取 0.5 中性值。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n**手动 L3 第一版状态(2026-07-15)**:已实现 `pq-v1-20260715` 固定 JSON canary,覆盖\nMessages、Chat Completions、Responses 与 Gemini 上游协议。调用方只能指定 `endpointUid`、\n`modelId` 和 1~3 次重复数,不能注入 prompt。评分仍按 40% 完整性、30% 语义正确性、\n15% 严格 JSON 格式、15% 延迟执行;第一版使用非流式完整响应延迟作为首 token 延迟代理,\n后续引入流式采样时再替换该维度。单次有效样本 confidence=0.6,最多三次提升到 0.8;\n失败样本按 0 分计入均值,全部失败不写画像。\n\n安全边界:仅管理 API 手动触发,不启动自动 worker;当前进程按 UTC 日设置 12 次硬预算,\n请求前原子预留,单次 90 秒超时、响应体上限 64 KiB。同一 endpoint×model 不并发执行;\n单次请求最多允许 2048 个输出 token,以容纳推理模型的隐藏思考并限制最坏额度消耗。\n对声明可控推理的模型固定采用最低可用 effort;Claude 兼容端点若只暴露\n`reasoning_content` 而未声明 thinking 模式,则与 capability-test 一致显式关闭 thinking。\n原始模型输出和明文 Key 既不落盘也不返回,只保存分数、canary 版本、置信度和脱敏判定。\n`user_feedback` 仍高于 `probe`,自动发现重建 ModelProfile 时必须保留既有质量证据。\n跨进程共享预算与 LLM-as-judge/语义向量评分仍属于后续阶段。\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设 + 自定义\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n| `custom` | 用户自定义 | 用户自定义 | 高级用户微调,范围 0.0 ~ 3.0 |\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法细分的通用任务;缺少基准证据时中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general;有规范 Knowledge 基准时使用其软评分,缺少直接证据时取 0.5 中性。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? canonicalBenchmark[model][domain] * providerQualityFactor\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 规范模型基准 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n#### 5.7.4 规范模型能力基准\n\n`shared/model-registry/ccx_model_registry.json` 的 `benchmarkProfiles` 独立保存规范模型能力上界,不与 `upstreamCapabilities` 混合。后者仍是 context、vision、tool calls 等硬能力的事实源;benchmark 只参与 Autopilot 软评分和 trace 解释,绝不改变 `supportedModels` 或 CapabilityFloor。\n\n```text\nEffectiveCapability[domain]\n = CanonicalCeiling[model, domain]\n × ProviderQualityFactor[endpoint]\n\nProviderQualityFactor = 1 - confidence × (1 - providerQualityScore)\n无渠道质量证据或 confidence<0.5 时 factor=1;可信证据只能从规范模型上界向下折算。\n```\n\n基准数据必须保留领域向量、来源、核验日期、lane、共享结果数和可比类别覆盖率,不能只保存站点 Overall。当前 BenchLM 接入为 `provisional`:Claude Opus 4.8、GPT-5.6 Terra、GPT-5.6 Sol 各有 30 个共享结果,8 类中 5 类可比,核验日期为 2026-07-13。\n\n领域映射:Knowledge → `general`,Math → `reasoning`,Coding → `coding` / `code_review`,Agentic → `agentic`,Multimodal → `aesthetics_ui`(仅弱代理,解释置信度减半)。Writing / Translation 没有直接类别证据时继续回退家族种子或 0.5。BenchLM 的 cohort 归一化、缺失项插补和外部校准决定了 Overall 不是绝对能力值,因此路由只消费可映射的原始类别分。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**请求意图边界(ModelRoutingPolicy)**\n\n跨模型替代不是所有模型的默认能力,只对白名单客户端入口开放:\n\n```text\nmessages:\n fable / opus / sonnet / haiku 及对应 claude-* 完整模型名 → adaptive\nresponses:\n gpt-5.6* / gpt-5.5 / gpt-5.4* / codex-auto-review → adaptive\n其他协议或模型:\n exact-only,只能在探测到相同规范化模型 ID 的 endpoint 之间调度\n```\n\n`adaptive` 允许在满足 Capability Floor 后选择 GLM、MiMo 等其他模型族;`exact-only` 禁止跨模型替代。用户显式配置的 `modelMapping` 仍优先于该策略。调度预览和 endpoint 实际解析必须调用同一分类函数,避免诊断结果与真实请求分叉。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ModelProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选优\n // 上下文窗口只负责准入,不对更大的窗口扣分。\n // 排序优先级:\n // a. 模型质量档越高越优先\n // b. provider 已确认的同档模型能力顺序\n // c. 带置信度折算的供应商实测质量\n // d. 探测延迟\n // e. 同渠道/provider 的模型相对消耗倍率\n // 倍率相同或缺失时再比较公开模型成本\n // f. 同模型族与 model ID 仅作为确定性兜底\n best := rankEligibleModels(eligible, requestModel)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n同渠道套餐倍率与公开 USD/Token 价格是不同单位,禁止直接混算。模型解析器先比较 provider\n提供的相对消耗倍率;只有双方倍率相同,或双方均无倍率时,才回退到统一输入/输出规模下的\n公开价格。优云智算当前内置倍率如下(数字越小越省,`glm-5.2` 的 2 次为限时值):\n\n| 模型 | 每次调用套餐消耗 |\n|------|-----------------:|\n| `glm-5.2` | 2 |\n| `glm-5.1` | 6 |\n| `MiniMax-M2.7` | 2 |\n| `kimi-k2.6` | 5 |\n| `deepseek-ai/DeepSeek-V3.2` | 1 |\n| `deepseek-v4-flash` | 1 |\n\nprovider 可以额外声明同一质量档内已确认的模型能力顺序,但只有该档全部候选都命中该元数据\n时才比较,避免把未知模型误判为弱模型,也保证排序不依赖候选输入顺序。优云模板当前记录\n`glm-5.1 > kimi-k2.6`,因此前者即使消耗 6 次,也会在后者的 5 次成本证据之前胜出。\n`glm-5.2` 即使消耗 2 次,也仍会在质量档高于 DeepSeek 候选时优先;V3.2 与 V4 Flash\n同为 1 次时,再由公开价格及最终确定性规则区分。静态 `modelMapping` 仅保留为用户显式覆盖,\n不承担自动替代策略。\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - autoManaged 的空 supportedModels 不表示支持全部,必须由 endpoint ModelProfile 裁决\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n当 ModelResolver 已正常初始化且路由处于 `assist/auto + autoResolve=true` 时,autoManaged 渠道的画像拒绝是权威结果,调度器不得再 fail-open 到空 `supportedModels` 的兼容语义。Resolver 不可用、开关关闭或 `off/shadow` 时仍保留原有 fail-open,保证急停和降级路径不依赖画像库。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\nGET /api/health-center/provider-quality/budget → 查看本进程手动 L3 每日预算\nPOST /api/health-center/provider-quality/probe → 对指定 endpoint×model 执行固定质量 canary\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n#### 8.0.1 实现现状与演进方向(2026-07-09 核实)\n\n四界面在 **Web 前端(`frontend/src`)已大面积落地**,不是纯文档:\n\n| 界面 | Web 实现 | Desktop 实现 |\n|------|----------|--------------|\n| 健康中心 | ✅ `HealthCenterView.vue` + `HealthCenterStats`/`HealthChannelTable`/`HealthChannelDetail`/`ProfileChangelogTimeline`,路由 `/health` | ❌ 无 |\n| 订阅中心 | ✅ `SubscriptionsView.vue` + `SubscriptionPlanTable`,CRUD + 刷新余额,路由 `/subscriptions` | ⚠️ 独立旧实现 `subscriptions/SubscriptionTab.vue`(与 Web 两套代码) |\n| 渠道中心 | ✅ `ChannelOrchestration.vue`(六态 `ChannelHealthBadge`、来源 chips、`autoManaged` 图标齐备) | ❌ 未同步 |\n| 管理面板 | ⚠️ `AutopilotModePanel.vue` 仅暴露 mode/killSwitch/costPreference | ❌ 无 |\n| 驾驶舱 | ✅ `CockpitView.vue`(观测型总览:健康统计+余额汇总+渠道推荐) | ⚠️ `cockpit` tab 渲染的是会话 Dashboard,职责不同 |\n\n**与本设计意图的关系**(对齐用户「自动调度为主、废旧手动驾驶舱」的诉求):\n- **旧的「手动调度驾驶舱」在 Web 端已不存在**。`CockpitView` 已是 §8.7 定义的观测型总览,只回答健康/省钱/风险/待办四问题,不做渠道配置编辑,符合「程序自动选、人只观测+有限干预」的方向。无需再废除,只需继续收敛为纯观测。\n- **统一提交入口已存在**(§8.4 快速添加 + §8.5 订阅中心),new-api 集成(§8.5.1)在此之上把「填 baseURL+key」进一步自动化。\n\n**前端演进待办**(本设计新增/明确):\n1. **补 `ManualRoutingIntent` 前端**:这是用户要的「有限偏向性干预」,后端已有、两个前端均未实现(§8.3 试用意图入口尚缺)。\n2. **Desktop 前端对齐 Web**:健康中心/渠道自动托管/管理面板/观测型驾驶舱在 Desktop 几乎空白,需同步或复用 Web 组件,避免长期维护两套。\n3. **管理面板补字段**:暴露 `l2ProbeEnabled`、探测预算、new-api/多来源订阅 adapter 开关(§8.5.2)等,当前只有 3 个字段。\n4. **订阅中心接 §8.5.1/§8.5.2**:新增「new-api」订阅类型与 Provider Adapter 能力矩阵驱动的动态表单。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.5.1 new-api 订阅集成(统一账号接入)\n\n**目标**:用户在订阅中心手填一个 new-api(及其 fork)账号的「系统访问令牌」即可,CCX 自动用该令牌创建代理专用 key、拉取分组倍率、查询额度余额,并把「站点 baseURL + 新建 key」自动落成一个上游渠道,交给 Autopilot 探测与调度。\n\n**范围约束**:\n- 只使用 new-api 用户在「个人设置」生成的 **系统访问令牌(access_token)** 一种凭据;令牌用于 API 调用的身份验证,属敏感数据,须妥善加密存储。\n- **不做** cookie 登录 + 2FA + 导出后台渠道明文 key 的管理员迁移路径(all-api-hub 的 newApiSession 能力),本设计不涉及。\n- new-api 归入 `OriginType=relay`、`OriginTier=second`(中转站),信任等级不因自动化提升;实时质量仍由运行画像决定。\n- 认证令牌像其他订阅凭据一样由用户手动录入,CCX 不代替用户在 new-api 侧注册或登录。\n\n**认证与凭据模型**:\n\nnew-api family 的管理接口鉴权(依据 all-api-hub 传输层实现):\n\n```text\nAuthorization: Bearer # 系统访问令牌;raw 模式可去掉 Bearer 前缀\nNew-API-User: # 当前用户 ID(必带)\nUser-id: # fork 兼容回退(Veloera/voapi/Super-API 等)\nContent-Type: application/json\n```\n\n统一响应信封:`{ \"success\": bool, \"data\": T, \"message\": string }`;解析时先判 `success`,再取 `data`。\n\n**SubscriptionProfile 扩展**:\n\n在 §3.2.2 的 `SubscriptionProfile` 基础上,为可自动接入的订阅新增 provider 集成字段(沿用「默认关闭 + 显式 opt-in」守则):\n\n```go\n// SubscriptionProfile 新增字段(复用 Phase 4 Item 6 的 SubscriptionAutoRefresh 开关)\ntype SubscriptionIntegration struct {\n Kind string `json:\"kind\"` // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | ...\n BaseURL string `json:\"baseUrl\"` // new-api 站点地址(同时作为新建渠道的上游 baseURL)\n AccessToken string `json:\"-\"` // 系统访问令牌;加密存储,不出 API 响应\n UserID string `json:\"userId\"` // New-API-User 头所需\n AuthTokenMode string `json:\"authTokenMode,omitempty\"` // \"bearer\"(默认) | \"raw\"\n // 自动建 key 的模板\n ProvisionKeyName string `json:\"provisionKeyName,omitempty\"` // 默认 \"ccx-autopilot\"\n ProvisionGroup string `json:\"provisionGroup,omitempty\"` // 建 key 时指定分组,空=默认分组\n ProvisionModels []string `json:\"provisionModels,omitempty\"` // model_limits,空=不限制\n // 自动同步产物(回填,只读展示)\n ProvisionedTokenID int `json:\"provisionedTokenId,omitempty\"`\n LinkedChannelUID string `json:\"linkedChannelUid,omitempty\"`\n}\n```\n\n`AccessToken` 必须与现有敏感字段一样走加密存储,序列化到前端时脱敏(仅显示尾部若干位)。\n\n**new-api 接口清单**(本集成实际调用的子集,路径以主线 new-api 为准,字段名保持与上游一致):\n\n| 用途 | 方法 | 路径 | 请求体 | 响应关键字段 |\n|------|------|------|--------|--------------|\n| 校验令牌 + 查用户/余额 | GET | `/api/user/self` | — | `data.id`、`data.username`、`data.quota`(剩余额度)、`data.used_quota` |\n| 拉分组倍率 | GET | `/api/user/self/groups` | — | `data: { : { desc, ratio } }` |\n| 站点全部分组 | GET | `/api/group` | — | `data: string[]` |\n| 账号可用模型 | GET | `/api/user/models` | — | `data: string[]` |\n| 创建代理 key | POST | `/api/token/` | `CreateTokenRequest`(见下) | `data.key` / `success` |\n| key 列表 | GET | `/api/token/?p={p}&size={n}` | — | `data.items[]`(含 `id/key/name/status/remain_quota`) |\n| 更新 key | PUT | `/api/token/` | `{ ...token, id }` | `success` |\n| 删除 key | DELETE | `/api/token/{id}` | — | `success` |\n\n`CreateTokenRequest`:\n\n```json\n{\n \"name\": \"ccx-autopilot\",\n \"remain_quota\": 0,\n \"expired_time\": -1,\n \"unlimited_quota\": true,\n \"model_limits_enabled\": false,\n \"model_limits\": \"\",\n \"allow_ips\": \"\",\n \"group\": \"\"\n}\n```\n\n`quota` 是 new-api 内部额度单位(非美元),换算比例读 `GET /api/status` 的 `price` / `PaymentUSDRate`(无鉴权),或允许用户在订阅中手填换算率;MVP 直接以 quota 原值展示 + 站点换算率估算余额。\n\n**接入流程**(订阅中心「新增订阅 → 类型选 new-api」):\n\n```text\n1. 用户输入:站点 baseURL + 系统访问令牌(access_token) + userId\n (userId 可选自动获取:先用令牌调 /api/user/self 拿 data.id 回填)\n2. 校验:GET /api/user/self\n ├─ success=false / 401 → 令牌无效或 userId 不匹配,报错停止\n └─ 成功 → 记录 username、quota,创建 SubscriptionProfile(provider=new_api, originTier=second)\n3. 拉分组:GET /api/user/self/groups → 写入 GroupMultipliers({group: ratio})\n GET /api/user/models → 记录账号可用模型,供渠道 supportedModels 参考\n4. 建 key:POST /api/token/(按 ProvisionKeyName/Group/Models 模板)\n ├─ 已存在同名 ccx-autopilot key → 复用其 id,不重复创建(列表比对 name)\n └─ 新建成功 → 回填 ProvisionedTokenID,取 data.key 作为渠道 apiKey\n5. 自动建渠道:用 baseURL + 新建 key 创建一个上游渠道\n ├─ channelKind 由用户选择或按站点探测(messages/chat/responses/gemini)\n ├─ status=unknown,autoManaged=true,OriginType=relay\n ├─ 建立 SubscriptionUID → ChannelUID 链接(回填 LinkedChannelUID)\n └─ 触发 §8.4 的 Discovery + 能力测试标准流程\n6. 完成:订阅卡片显示余额、分组倍率、已绑定渠道、探测进度\n```\n\n关键点:new-api 集成**不额外造探测逻辑**,第 5 步之后完全复用现有 §8.4「添加并探测」链路;集成层只负责「令牌 → key + 分组 + 余额 + baseURL」的自动填充,把手填 baseURL/key 的动作自动化掉。\n\n**刷新与同步**(复用 `POST /api/subscriptions/{uid}/refresh`):\n\n| 触发 | 动作 |\n|------|------|\n| 手动刷新余额 | `GET /api/user/self` 更新 `Balance` + `BalanceUpdatedAt` |\n| 定时刷新(可选,opt-in) | 复用 Phase 4 Item 6 `SubscriptionAutoRefresh`;new-api 作为 `relay` provider adapter 纳入,默认关闭 |\n| 分组倍率变更 | 刷新时对比 `/api/user/self/groups`,`GroupMultipliers` 变化 → 更新并提示「套餐倍率已变」 |\n| key 失效检测 | 渠道侧探测到该 key 全 401 → 订阅卡片提示「key 可能已删除/禁用」,提供「重新建 key」一键动作(重跑第 4-5 步,复用旧渠道,只换 apiKey) |\n| 可用模型变更 | `/api/user/models` 结果哈希变化 → 触发对应渠道重新 Discovery |\n\n**安全与边界**:\n- `access_token` 权限等同 new-api 账户,须加密存储、脱敏展示、禁止写入日志;刷新失败连续 N 次自动停用该订阅的自动刷新,只保留手动。\n- 自动建 key 默认 `unlimited_quota=true` 但可由用户改为限额模板;建 key 是有副作用的写操作,须在接入前明确告知用户「将在 new-api 侧创建一个名为 ccx-autopilot 的令牌」。\n- new-api 是 `OriginTier=second`,**不得**用作隐私敏感的 routing advisor / classifier(遵守 §3.2.1 约束)。\n- fork 兼容:请求同时带 `New-API-User` 与 `User-id` 头;响应信封字段名(`success/data/message`)在部分 fork 可能不同,adapter 需容错解析,失败时回退手动录入。\n- 一个 new-api 订阅默认只自动建/绑定一个渠道;用户如需该站多协议渠道,可在同订阅下手动增建,共用同一 key。\n\n### 8.5.2 订阅 Provider Adapter 框架(多来源余额与接入)\n\n§8.5.1 的 new-api 集成不是特例,而是「订阅 Provider Adapter」框架的一个实现。Phase 4 Item 6 已落地 openai/anthropic/google 的余额刷新 adapter,本框架把它抽象成统一接口,让 new-api 及后续来源(GitHub Copilot、Codex、Nvidia、MiniMax、DeepSeek 等)按相同契约接入,避免每加一个来源就散落一套逻辑(DRY)。\n\n**统一接口**:\n\n```go\n// backend-go/internal/autopilot/subscription/adapter.go\ntype ProviderAdapter interface {\n Kind() string // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | \"copilot\" | \"deepseek\" | ...\n\n // 校验凭据,返回账号身份(用户名/账户标识);失败即凭据无效\n Verify(ctx context.Context, cred Credential) (AccountInfo, error)\n // 查余额/额度;不支持返回 ErrUnsupported,由 UI 降级为手动录入\n FetchBalance(ctx context.Context, cred Credential) (Balance, error)\n // 拉分组倍率;仅 new-api 类中转有,官方直连返回 ErrUnsupported\n FetchGroups(ctx context.Context, cred Credential) (map[string]GroupRatio, error)\n // 自动创建代理 key;官方直连通常不支持(key 即用户 API key),返回 ErrUnsupported\n ProvisionKey(ctx context.Context, cred Credential, tmpl KeyTemplate) (ProvisionedKey, error)\n // 可用模型列表;用于渠道 supportedModels 参考\n FetchModels(ctx context.Context, cred Credential) ([]string, error)\n}\n```\n\n每个能力独立、可选:adapter 只实现自己支持的部分,不支持的统一返回 `ErrUnsupported`(接口隔离原则),UI 据此隐藏/降级对应入口。所有 adapter 共用 `SubscriptionAutoRefresh` 的 opt-in 开关与 `Validate()` 兜底,与 Phase 4 现有防御模式一致。\n\n**能力矩阵**(✅ 支持 / ⚠️ 部分或需确认 / ❌ 不支持,走手动):\n\n| 来源 | Kind | 验证 | 查余额 | 拉分组 | 自动建 key | OriginTier |\n|------|------|:----:|:------:|:------:|:----------:|------------|\n| new-api family | `new_api` | ✅ | ✅ quota | ✅ | ✅ | second |\n| OpenAI 官方 | `openai` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Anthropic 官方 | `anthropic` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Google 官方 | `google` | ✅ | ⚠️ | ❌ | ❌ | first |\n| GitHub Copilot | `copilot` | ⚠️ token | ❌ 无额度概念 | ❌ | ❌ | first |\n| Codex plan | `codex` | ⚠️ | ⚠️ 用量窗口 | ❌ | ❌ | first |\n| Nvidia NIM | `nvidia` | ✅ | ⚠️ credits | ❌ | ❌ | first |\n| MiniMax | `minimax` | ✅ | ⚠️ | ❌ | ❌ | first |\n| DeepSeek | `deepseek` | ✅ | ✅ balance API | ❌ | ❌ | first |\n\n说明:官方直连来源普遍**不支持自动建 key**(用户的 API key 本身就是凭据,直接作为渠道 apiKey 录入即可)、也**没有分组倍率**(价格按官方定价表)。它们的 adapter 价值集中在「验证 + 查余额/用量窗口」,把 §3.2.4 UsageWindow 的官方用量 API 来源坐实。DeepSeek 有独立余额查询端点,可完整支持余额。Copilot 属订阅制、无 token 余额概念,只做验证 + 用量提示。\n\n**接入分层**(YAGNI:不强求一次做全,按价值排期):\n\n```text\nP0(本轮):new_api(完整:验证+余额+分组+建key+自动建渠道)\n —— 覆盖用户最主要的中转站场景\nP1:deepseek(验证+余额)、官方三家余额已在 Phase 4 完成 → 补 UsageWindow 对齐\nP2:nvidia / minimax(验证+credits/余额)\nP3:copilot / codex(验证+用量窗口提示,无余额)\n```\n\n每个来源的具体端点在实现时于 `adapter.go` 各 provider 文件内落地,本设计只固定接口契约与能力矩阵,避免文档与上游 API 变动强耦合。新增来源 = 实现 `ProviderAdapter` + 在能力矩阵登记,不改框架。\n\n**能力缺失与降级**:\n- adapter 任一能力返回 `ErrUnsupported` → 订阅中心对应字段回退为手动录入(余额手填、倍率手填),不报错、不阻塞接入。\n- adapter 验证失败/超时 → 订阅可仍以「手动模式」创建,仅失去自动刷新与自动建 key,其余流程(手填 baseURL+key 建渠道)不受影响。\n- 官方来源无「自动建 key」时,接入流程退化为 §8.4 标准快速添加:用户填 API key,adapter 只补验证与余额。\n- 所有自动刷新遵守 `SubscriptionAutoRefresh` 默认关闭;连续失败自动停用自动刷新并在驾驶舱「人工待办」提示。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3,\n \"providerTimePricing\": {\n \"deepseek\": {\n \"effectiveFrom\": \"2026-07-20T00:00:00+08:00\",\n \"timeZone\": \"Asia/Shanghai\",\n \"peakMultiplier\": 2,\n \"peakWindows\": [\n { \"start\": \"09:00\", \"end\": \"12:00\" },\n { \"start\": \"14:00\", \"end\": \"18:00\" }\n ]\n }\n }\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n`providerTimePricing.effectiveFrom` 使用 RFC3339;修改该值即可调整激活时间,设为空字符串则停用对应规则。\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**Phase 3A 状态(2026-07-09)**:低风险、不改真实路由语义的子集已完成并接入真实链路——`Manager.collectAll()` 新旧画像对比产出变更事件(`DetectProfileChanges`)、`ProfileChangelogStore`(环形内存 + SQLite,30 天机会性清理)、`EventHub` 内存 pub/sub、`GET /api/health-center/changelog`(历史)+ `GET /api/health-center/events`(WebSocket 实时推送)、`AutoDiscoveryRunner` 发布 `discovery_completed`/`auto_mapping_applied`、前端 `ProfileChangelogTimeline.vue` 接入健康中心页并支持断线自动重连。均为只读展示,未修改 `SmartRouter`/`EndpointAttemptPolicy` 调度逻辑。落地时顺带发现并修复一个真实缺口:浏览器原生 WebSocket 无法设自定义 header,为此给 `middleware.getAPIKey` 增加了 `Sec-WebSocket-Protocol` 鉴权回退(业界标准做法,仅新增一个 key 来源,不降低现有校验)。\n\n**Phase 3B(已全部完成,2026-07-09)**:模型自动映射、自动恢复探测、晋升/降级机制会直接改变真实调度决策,风险明显高于 3A,四个子项已分批全部完成。§12.2 P1.5 的 SQLite schema version / 版本化 migration / `OriginType`&`OriginTier` 旧配置 backfill / `killSwitch`+按 task class/channel disable **已于 2026-07-09 补强完成**(详见 §12.2 P1.5)。**Phase 3B-1(自动恢复探测补全)已于 2026-07-09 完成**:修复了 L2 探测子系统里此前从未打通的两个真实 bug——① `collectAll()` 每轮把新构造的画像整行 Upsert,会把 `ProbeWorker` 刚写入的 `LastProbeAt`/`ProbeSuccess`/`ConsecutiveProbeSuccess` 等字段清零,导致 6 小时探测冷却期形同虚设(修复:`carryForwardProbeFields` 在 Upsert 前搬运 Probe* 字段,不影响 L1 诊断字段的现有覆盖逻辑);② `extractRawAPIKey` 是返回 `KeyMask` 掩码值的占位 stub,探测请求实际带的是假 key(修复:新增 `APIKeyResolver` 回调 + `Manager.ResolveAPIKey`,通过遍历配置里对应渠道的 `APIKeys` 逐个计算 `KeyHashFromAPIKey` 比对来还原明文;resolver 未注入或未命中时 fail-open 跳过探测且不消耗预算)。同时补齐了 `degraded/limited → healthy` 的连续成功恢复路径(新增 `KeyEndpointProfile.ConsecutiveProbeSuccess` 计数器 + `HealthCheckConfig.ProbeRecoveryThreshold`,默认连续 2 次探测成功才恢复,避免单次探测噪声导致状态抖动;探测失败计数器清零)。**Phase 3B-3(StabilityTier 晋降级滞后)已于 2026-07-09 完成**(提交 664d3fff):在原本每轮从瞬时 1h 窗口重算的无记忆 `StabilityTier` 上加一层连续窗口滞后——新增 `KeyEndpointProfile.EffectiveStabilityTier`/`StabilityPendingTier`/`StabilityPendingStreak` 三字段 + 纯函数 `applyStabilityHysteresis`(对称阈值:连续 N 轮同值才切换生效档,`HealthCheckConfig.StabilityHysteresisWindows` 默认 2;首次画像直接采用,不等待阈值,避免新渠道卡在零值),`AggregateChannelProfile` 改读 `EffectiveStabilityTier`(空则回退 `StabilityTier`)。晋升/降级机制的数据模型仅限于 StabilityTier,不改变 QualityTier/SpeedTier/CostTier 语义。**Phase 3B-2(模型自动映射闭环)已于 2026-07-09 完成**(提交 0f02e988,随后修复一处 critical 集成 bug,提交 856372c4):新建 `ModelProfileStore`(SQLite,`model_profiles` 表)+ 扩展 `AutoDiscoveryRunner` 为每个发现模型写入 `ModelProfile`(仅 `AutoManaged` 渠道);新建 `ModelResolver` 实现 §5.4 五步逻辑(手动 `RedirectModel` 短路 → `GetModelProfiles` → `filterByCapabilityFloor` → `rankBySimilarity`);`scheduler` 包新增 `ModelSupportResolverFunc` hook(fail-open,未注入或返回不支持时严格回退到原 `ExplainModelSupport`);`endpoint_policy.go` 的 `resolveMappedModel`(此前是永不触发的死代码——`profile.ModelMapping` 从未被写入)现调用 `ModelResolver`;`handlers/common` 在 `RedirectModel` 之后应用 `MappedModel` 覆盖请求体,并按 `EchoMappedModel` 配置回显 `X-CCX-Mapped-Model`/`X-CCX-Original-Model`/`X-CCX-Mapping-Source` 响应头。三条件安全门控:`ModelMapping.AutoResolve==true`(**默认由死配置的 true 改为 false**)AND `RoutingMode ∈ {assist, auto}` AND 无 `KillSwitch`,三者任一不满足则所有新增代码路径行为与今天字节级一致;且仅 `AutoManaged` 渠道触发,手动配置渠道完全不受影响。对抗性复核发现并修复一个真实 critical bug:`scoreEndpointForKey` 用空 `channelUID` 计算 `EndpointUID` 并写入 `modelByUID`,而 `handlers` 层用真实 `channelUID` 查 `modelByUID`,两者哈希永不相等导致 `MappedModel` 在 key 级调度路径上完全查不到(提交 856372c4 修复:命中画像后改用 `profile.EndpointUID`,新增回归测试锁定该不变量)。至此 Phase 3B 四个子项(自动恢复探测、StabilityTier 滞后、模型自动映射、active filter 前置判定)全部完成;SLO regression 自动回滚仍是独立子系统,留待后续单独立项。\n\n**范围**:\n- [x] 运行时指标驱动画像实时更新(Phase 3A:`collectAll()` 每轮 diff + changelog + WebSocket 推送)\n- [x] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)—— Phase 3B-2(2026-07-09 完成,提交 0f02e988 + 856372c4)\n- [x] active model filter / context filter 支持自动映射前置判定 —— Phase 3B-2:`scheduler.ModelSupportResolverFunc` fail-open 接入 `select.go`\n- [x] 自动恢复探测(limited/dead → healthy)—— Phase 3B-1(2026-07-09 完成):`APIKeyResolver` + `carryForwardProbeFields` + `ConsecutiveProbeSuccess`/`ProbeRecoveryThreshold`\n- [x] 晋升/降级机制(连续成功→升级,连续失败→降级)—— Phase 3B-3(2026-07-09 完成,提交 664d3fff):`StabilityTier` 连续窗口滞后(`EffectiveStabilityTier`),暂不含 QualityTier/SpeedTier/CostTier\n- [x] WebSocket 推送画像变更事件(Phase 3A:`GET /api/health-center/events`)\n- [x] 前端画像变更历史/时间线(Phase 3A:`ProfileChangelogTimeline.vue`)\n\n**预估工期**:2-3 周(3A、3B-1、3B-2、3B-3 均已完成;advisor 与全局 auto 的 SLO 回滚均已补齐)\n\n> 状态校正(2026-07-17):上方 Phase 3B 历史说明中的“SLO regression 自动回滚待单独立项”已完成;advisor 回滚与全局 auto 上线闸门/自动降级是两个独立保护层。\n\n### Phase 4:高级特性\n\n**Phase 4 状态(2026-07-09,全部完成)**:8 项全部实现并接入真实链路,均遵循\"默认关闭 + 显式 opt-in\"安全守则。\n\n**范围**:\n- [x] 多维度标签系统扩展(用户自定义标签)——Item 1\n- [x] 成本报表:按用户/模型/渠道/key 统计真实有效成本——Item 2(新增 `proxyKeyMask` 身份贯穿链路:`middleware.GetProxyKeyMask` → `ChannelLog`/`RequestRecord` → SQLite `proxy_key_mask` 列,供 Item 4 复用)\n- [x] Trusted advisor SLO regression 自动回滚——Item 3(默认关闭;`SLORollbackConfig`,连续 degrading 窗口数默认 3,不做自动恢复以避免震荡)\n- [x] 渠道推荐:根据使用模式推荐新渠道——Item 4\n- [x] 批量渠道管理(导入/导出/模板)——Item 5\n- [x] 订阅中心 provider adapter:可选自动刷新余额/套餐状态——Item 6(默认关闭;仅 openai/anthropic/google 且显式填写 BillingAPIKey 的订阅生效,中转/公益渠道继续手动维护)\n- [x] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板——Item 7\n- [x] A/B 测试:低比例统计抽样双发——Item 8(默认关闭,默认 1% 抽样 + 每小时 60 次硬预算;主请求路径不受影响,影子请求在主响应返回后异步发起)\n\n**关键实现说明**:\n- Item 3/6/8 均在 `AutopilotRoutingConfig` 新增独立配置块(`SLORollback`/`SubscriptionAutoRefresh`/`ABTest`),`Validate()` 统一做负值/越界兜底,保持与既有字段一致的防御模式。\n- Item 4 直接复用 Item 2 的 `proxyKeyMask` 身份标记,未重新发明身份识别机制。\n- **全局 auto 安全闸门(2026-07-17)**:请求终态按 15 分钟窗口无偏聚合,详细 trace 继续抽样;管理 API 只在 shadow/assist 已积累至少 500 个有效请求、24 小时连续观测且成功率/fallback/fail-open/p95 均达标时允许切换 `auto`。后台连续检测三个已完成 auto 窗口,相对最近七天安全模式基线持续恶化时,持久化降级到 `assist` 并记录 `autopilot_auto_safety_events`,不自动恢复。\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- ~~SQLite schema version 和幂等 migration。~~ **已完成**:`ensureSchemaVersion` 基于 `PRAGMA user_version`,当前 7 张表定版为 v1 基线;库版本高于代码版本时返回 error(fail-closed,不做任何写操作),交给下游 fail-open 分支接管。\n- ~~旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。~~ **已完成**:`ensureOriginBackfill` 只补 `\"unknown\"`,不做任何基于 URL/名称的猜测推断;已接线到 `Profiler.DeriveEndpointProfile`,`BreakTieByOriginTier` 现在能吃到真实数据(此前一直是 fallback 的 `unknown`)。\n- ~~profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。~~ **已确认满足并测试锁定**:`main.go` 现有结构本就是 fail-open(`NewProfileStore`/`NewManager` 出错只打日志、`autopilotManager` 保持 `nil`,下游全部有 `nil` 守卫),本次新增 `schema_migration_test.go` 用测试锁定这条不变量。\n- ~~全局 `killSwitch`、按 task class disable、按 channel disable。~~ **已完成**:新增 `AutopilotRoutingConfig.DisabledTaskClasses`/`DisabledChannelUIDs`;`CandidateFilterFor`/`executeFilter`/`BuildPlan` 三处一致接入,`smart_router_invariant_test.go` 覆盖\"task class 禁用后回退默认调度\"\"channel 禁用后不出现在候选里\"\"dry-run 与真实路径行为一致\"三条不变量。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。**部分完成**:`ReleaseController.EvaluateAndApplyRegression` 已实现三窗口回归检测和 `SafetyOverride` 降级机制;`RoutingModeActive` 已加入配置枚举和 `IsAutopilotActive`。\n\n#### P1.6 Trace v2 契约与灰度发布\n\n> 对应实施计划:`docs/superpowers/plans/2026-07-23-autopilot-trace-rollout.md`\n\n**Trace v2 核心变更:**\n\n- `RoutingDecisionTrace` 增加 `SchemaVersion`、`TraceRevision`、`RequestCorrelationId`、`ReleaseID`、`PolicyFingerprint`、`TargetMode`/`EffectiveMode`/`Cohort`、`SchedulerDecision`、`EndpointAttempts` 字段。\n- 新增 `TraceDetailV2`、`TraceSummary`、`SchedulerDecisionSummary`、`EndpointAttemptSummary` DTO;使用显式 DTO 落盘而非直接 JSON 化运行时结构。\n- `ComparisonStatus` 三态枚举(`matched`/`mismatched`/`uncompared`)替代裸 `bool`。\n- `SanitizeForPersistence` 与 `SanitizeForResponse` 双边界脱敏;`GenerateTraceUIDv2` 使用 `crypto/rand` 碰撞安全生成。\n- `RoutingReleaseSnapshot` 不可变发布快照,入口冻结后续只读。\n\n**SQLite v6 迁移:**\n\n- trace 表新增列:`schema_version`、`trace_revision`、`request_correlation_id`、`release_id`、`policy_fingerprint`、`persistence_class`、`details_json`。\n- 窗口表重建主键:增加 `release_id`/`policy_fingerprint`/`cohort` 维度,新增 `compared_count`/`matched_count`/`mismatch_count`/`uncompared_count` 全量比较计数。\n- safety event 表增加 `release_id`/`policy_fingerprint` 字段。\n\n**灰度发布状态机:**\n\n```text\noff --管理员启用--> shadow --门槛满足--> assist --门槛满足--> auto --100% 稳定--> active\n```\n\n| 状态 | 行为 | 放量规则 |\n|---|---|---|\n| `off` | 不调用 Autopilot | rolloutPercent=0 |\n| `shadow` | 计算+记录,不影响调度 | rolloutPercent=0,安全基线 |\n| `assist` | 重排候选,不删除 | rolloutPercent 1→100 |\n| `auto` | 硬约束过滤+重排 | rolloutPercent 1→100 |\n| `active` | 与 auto 语义相同 | rolloutPercent=100,1% shadow control |\n\n**ReleaseController**(`release_controller.go`):\n\n- 集中处理相邻状态迁移校验(逐级晋升,降级随时允许)。\n- `ComputeCohort` 使用 session ID / request correlation ID 与 `rolloutSeed` 做稳定哈希分桶。\n- `EvaluateAndApplyRegression` 检查三窗口回归并应用 `SafetyOverride`。\n- `AllowedTransition` 禁止跳级,`modeRank` 确定顺序。\n\n**只读 Trace API:**\n\n| 接口 | 返回 | 约束 |\n|---|---|---|\n| `GET /api/autopilot/traces` | `TraceSummary[]` | 游标分页、release/cohort/mode 过滤、partial 标记 |\n| `GET /api/autopilot/traces/:traceUid` | `TraceDetailV2` | 404=未找到/已过期/未采样;503=存储不可用 |\n| `GET /api/autopilot/traces/stats` | `TraceStatsResponse` | 三态比较统计、Mode/TaskClass 分布 |\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | shadow 样本/准确率/critical misroute 门槛;active 后 SLO regression 自动 rollback;全局 kill switch |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "59c3927ffbcde49bc2f2a8495991028e87c7deeb": {"t": 1785759344.8328261, "n": 158791, "h": "6c6863298359de65", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.2.3 同订阅多 Key:能力共享与配额负载均衡\n\n**问题**:官方订阅 plan(Claude Max、Codex plan 等)允许用户持有多个 key,或购买多份相同套餐。若每个 endpoint 独立探测与配置,探测预算被浪费、配置重复;请求量大时还需要在同 plan 的多个 key 之间按官方 RPM 限速自动负载均衡,而不是打满第一个 key 才溢出到下一个。\n\n**能力检测去重(同一套餐只需一套能力检测和配置)**:\n\n```text\nSubscriptionUID 相同的 endpoint 共享一份「订阅级能力画像」:\n 模型列表 / vision / tool / reasoning 支持 / 协议兼容开关 → 只探测一次,\n 存在 SubscriptionProfile.SharedCapability,endpoint 画像以引用方式继承。\n\n仍然 per-endpoint 维护的:健康状态、延迟、熔断、用量窗口\n (key 可能被单独封禁/限速,能力相同不代表运行态相同)\n\n继承失效条件:某 key 实测模型列表哈希与订阅级不一致(GroupChangeDetector 信号)\n → 该 key 脱离继承、独立探测,并在 UI 标记「与套餐能力不一致」警告。\n```\n\n**配额负载均衡(Phase 1 shadow 展示,Phase 2 生效)**:\n\n```text\n每 key 维护配额余量视图:\n remainingRPM = officialRPM − 当前窗口已用\n officialRPM 来源优先级:内置 manifest > 用户配置 > RateLimitDiscoverer 建议\n\n同订阅多 key 时按「剩余余量加权」分配请求(余量大者优先、\n窗口最早重置者兜底),替代顺序打满:\n 仅当渠道 autoManaged 且同一 SubscriptionUID 下 key 数 > 1 时启用;\n 否则保持现有 sequential/random key 顺序策略不变。\n\n某 key 的窗口配额耗尽 → 临时降权并切到同订阅其他 key,\n不熔断整个渠道(这是配额耗尽,不是故障)。\n```\n\n### 3.2.4 订阅用量窗口 (Usage Windows)\n\n部分官方 plan 提供 5h / 日 / 周 / 月等窗口的用量查询与限制(如 Claude 订阅的 5h 滚动窗口 + 周配额)。在 key 的位置展示这些窗口,帮助用户掌握使用情况:\n\n```go\ntype UsageWindow struct {\n Window string `json:\"window\"` // \"5h\" | \"day\" | \"week\" | \"month\"\n Used float64 `json:\"used\"` // 已用量\n Limit float64 `json:\"limit\"` // 上限,0 = 未知\n Unit string `json:\"unit\"` // requests | tokens | credits | percent\n ResetsAt time.Time `json:\"resetsAt\"` // 窗口重置时间\n Source string `json:\"source\"` // official_api | response_header | local_metering\n FetchedAt time.Time `json:\"fetchedAt\"`\n}\n// KeyEndpointProfile 与 SubscriptionProfile 均增加 UsageWindows []UsageWindow\n```\n\n数据来源优先级:\n\n```text\n1. 官方用量 API:有 provider adapter 的 plan 主动拉取(§Phase 4 订阅 adapter 的可提前子集)\n2. 响应头:anthropic-ratelimit-* / x-ratelimit-* 等(复用 RateLimitDiscoverer 的信号 tap)\n3. 本地计量兜底:CCX 按 key 累计请求数/token(误差来自窗口起点对齐,标注 Source=local_metering)\n```\n\nUI 要求:key 行内显示主窗口(5h)用量进度条,悬浮展开全部窗口;用量 >80% 黄色预警、超限红色并显示预计重置时间。调度联动(Phase 2):窗口余量进入配额负载均衡权重,窗口耗尽的 key 临时降权而非熔断。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n#### 4.2.1 内置模型清单 (Builtin Models Manifest)\n\n**问题**:部分官方订阅 plan 的入口没有正确的 models 接口——要么不存在(404)、要么需要额外权限、要么返回与订阅实际可用模型不符的列表(典型:Claude 订阅 OAuth 入口、Codex plan 入口、部分官方企业网关)。对这些 baseURL 做模型发现会失败或产出错误画像。\n\n**方案**:CCX 内置「已知官方 baseURL → 模型清单」映射表,命中时直接返回内置内容,完全跳过上游 models 接口:\n\n```go\n// backend-go/internal/config/builtin_models_manifest.go(生成式,随模型注册表更新)\ntype BuiltinModelsManifest struct {\n BaseURLPattern string `json:\"baseUrlPattern\"` // 精确 host 或前缀匹配,如 \"api.anthropic.com\"\n ServiceType string `json:\"serviceType\"` // messages/responses/…\n PlanHint string `json:\"planHint,omitempty\"` // 订阅类型提示(如 \"claude_subscription\")\n ModelIDs []string `json:\"modelIds\"` // 该入口实际可用的模型清单\n DisableProbe bool `json:\"disableProbe\"` // true 时 Discovery 跳过 GET /v1/models\n}\n```\n\n**两个生效位置**:\n\n```text\n1. Discovery(§4.2 流程第 2 步之前):\n baseURL 命中 manifest 且 DisableProbe=true\n → 跳过 GET /v1/models,直接用 ModelIDs 生成 ModelProfile[]\n → ProfileSource = \"builtin_manifest\",不再因 models 接口失败而误判 misconfigured\n\n2. /v1/models 代理响应(现有 Models API):\n 客户端向 CCX 请求 /v1/models 时,聚合结果中该渠道的贡献\n 直接取内置清单,不向上游发起 models 请求\n (渠道显式配置了 supportedModels 时仍以用户配置优先)\n```\n\n**维护与覆盖顺序**:\n\n```text\n渠道显式 supportedModels(用户手动) > 内置 manifest > 上游 models 接口实探 > 内置候选模型回退\n```\n\nmanifest 与模型注册表同源生成(`generated_model_registry.go` 同级),新模型发布时随注册表一起更新;HealthAnalyzer 对命中 manifest 的 endpoint 不将「models 接口 404/403」计入 misconfigured 信号。\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20;assist 下 dead/misconfigured 额外 -100 并保留在队尾\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法细分时进入 `general`;有规范 Knowledge 基准则使用其软评分,缺少直接证据时仍取 0.5 中性值。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n**手动 L3 第一版状态(2026-07-15)**:已实现 `pq-v1-20260715` 固定 JSON canary,覆盖\nMessages、Chat Completions、Responses 与 Gemini 上游协议。调用方只能指定 `endpointUid`、\n`modelId` 和 1~3 次重复数,不能注入 prompt。评分仍按 40% 完整性、30% 语义正确性、\n15% 严格 JSON 格式、15% 延迟执行;第一版使用非流式完整响应延迟作为首 token 延迟代理,\n后续引入流式采样时再替换该维度。单次有效样本 confidence=0.6,最多三次提升到 0.8;\n失败样本按 0 分计入均值,全部失败不写画像。\n\n安全边界:仅管理 API 手动触发,不启动自动 worker;当前进程按 UTC 日设置 12 次硬预算,\n请求前原子预留,单次 90 秒超时、响应体上限 64 KiB。同一 endpoint×model 不并发执行;\n单次请求最多允许 2048 个输出 token,以容纳推理模型的隐藏思考并限制最坏额度消耗。\n对声明可控推理的模型固定采用最低可用 effort;Claude 兼容端点若只暴露\n`reasoning_content` 而未声明 thinking 模式,则与 capability-test 一致显式关闭 thinking。\n原始模型输出和明文 Key 既不落盘也不返回,只保存分数、canary 版本、置信度和脱敏判定。\n`user_feedback` 仍高于 `probe`,自动发现重建 ModelProfile 时必须保留既有质量证据。\n跨进程共享预算与 LLM-as-judge/语义向量评分仍属于后续阶段。\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设 + 自定义\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n| `custom` | 用户自定义 | 用户自定义 | 高级用户微调,范围 0.0 ~ 3.0 |\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法细分的通用任务;缺少基准证据时中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general;有规范 Knowledge 基准时使用其软评分,缺少直接证据时取 0.5 中性。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? canonicalBenchmark[model][domain] * providerQualityFactor\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 规范模型基准 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n#### 5.7.4 规范模型能力基准\n\n`shared/model-registry/ccx_model_registry.json` 的 `benchmarkProfiles` 独立保存规范模型能力上界,不与 `upstreamCapabilities` 混合。后者仍是 context、vision、tool calls 等硬能力的事实源;benchmark 只参与 Autopilot 软评分和 trace 解释,绝不改变 `supportedModels` 或 CapabilityFloor。\n\n```text\nEffectiveCapability[domain]\n = CanonicalCeiling[model, domain]\n × ProviderQualityFactor[endpoint]\n\nProviderQualityFactor = 1 - confidence × (1 - providerQualityScore)\n无渠道质量证据或 confidence<0.5 时 factor=1;可信证据只能从规范模型上界向下折算。\n```\n\n基准数据必须保留领域向量、来源、核验日期、lane、共享结果数和可比类别覆盖率,不能只保存站点 Overall。当前 BenchLM 接入为 `provisional`:Claude Opus 4.8、GPT-5.6 Terra、GPT-5.6 Sol 各有 30 个共享结果,8 类中 5 类可比,核验日期为 2026-07-13。\n\n领域映射:Knowledge → `general`,Math → `reasoning`,Coding → `coding` / `code_review`,Agentic → `agentic`,Multimodal → `aesthetics_ui`(仅弱代理,解释置信度减半)。Writing / Translation 没有直接类别证据时继续回退家族种子或 0.5。BenchLM 的 cohort 归一化、缺失项插补和外部校准决定了 Overall 不是绝对能力值,因此路由只消费可映射的原始类别分。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**请求意图边界(ModelRoutingPolicy)**\n\n跨模型替代不是所有模型的默认能力,只对白名单客户端入口开放:\n\n```text\nmessages:\n fable / opus / sonnet / haiku 及对应 claude-* 完整模型名 → adaptive\nresponses:\n gpt-5.6* / gpt-5.5 / gpt-5.4* / codex-auto-review → adaptive\n其他协议或模型:\n exact-only,只能在探测到相同规范化模型 ID 的 endpoint 之间调度\n```\n\n`adaptive` 允许在满足 Capability Floor 后选择 GLM、MiMo 等其他模型族;`exact-only` 禁止跨模型替代。用户显式配置的 `modelMapping` 仍优先于该策略。调度预览和 endpoint 实际解析必须调用同一分类函数,避免诊断结果与真实请求分叉。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ModelProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选优\n // 上下文窗口只负责准入,不对更大的窗口扣分。\n // 排序优先级:\n // a. 模型质量档越高越优先\n // b. provider 已确认的同档模型能力顺序\n // c. 带置信度折算的供应商实测质量\n // d. 探测延迟\n // e. 同渠道/provider 的模型相对消耗倍率\n // 倍率相同或缺失时再比较公开模型成本\n // f. 同模型族与 model ID 仅作为确定性兜底\n best := rankEligibleModels(eligible, requestModel)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n同渠道套餐倍率与公开 USD/Token 价格是不同单位,禁止直接混算。模型解析器先比较 provider\n提供的相对消耗倍率;只有双方倍率相同,或双方均无倍率时,才回退到统一输入/输出规模下的\n公开价格。优云智算当前内置倍率如下(数字越小越省,`glm-5.2` 的 2 次为限时值):\n\n| 模型 | 每次调用套餐消耗 |\n|------|-----------------:|\n| `glm-5.2` | 2 |\n| `glm-5.1` | 6 |\n| `MiniMax-M2.7` | 2 |\n| `kimi-k2.6` | 5 |\n| `deepseek-ai/DeepSeek-V3.2` | 1 |\n| `deepseek-v4-flash` | 1 |\n\nprovider 可以额外声明同一质量档内已确认的模型能力顺序,但只有该档全部候选都命中该元数据\n时才比较,避免把未知模型误判为弱模型,也保证排序不依赖候选输入顺序。优云模板当前记录\n`glm-5.1 > kimi-k2.6`,因此前者即使消耗 6 次,也会在后者的 5 次成本证据之前胜出。\n`glm-5.2` 即使消耗 2 次,也仍会在质量档高于 DeepSeek 候选时优先;V3.2 与 V4 Flash\n同为 1 次时,再由公开价格及最终确定性规则区分。静态 `modelMapping` 仅保留为用户显式覆盖,\n不承担自动替代策略。\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - autoManaged 的空 supportedModels 不表示支持全部,必须由 endpoint ModelProfile 裁决\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n当 ModelResolver 已正常初始化且路由处于 `assist/auto + autoResolve=true` 时,autoManaged 渠道的画像拒绝是权威结果,调度器不得再 fail-open 到空 `supportedModels` 的兼容语义。Resolver 不可用、开关关闭或 `off/shadow` 时仍保留原有 fail-open,保证急停和降级路径不依赖画像库。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\nGET /api/health-center/provider-quality/budget → 查看本进程手动 L3 每日预算\nPOST /api/health-center/provider-quality/probe → 对指定 endpoint×model 执行固定质量 canary\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n#### 8.0.1 实现现状与演进方向(2026-07-09 核实)\n\n四界面在 **Web 前端(`frontend/src`)已大面积落地**,不是纯文档:\n\n| 界面 | Web 实现 | Desktop 实现 |\n|------|----------|--------------|\n| 健康中心 | ✅ `HealthCenterView.vue` + `HealthCenterStats`/`HealthChannelTable`/`HealthChannelDetail`/`ProfileChangelogTimeline`,路由 `/health` | ❌ 无 |\n| 订阅中心 | ✅ `SubscriptionsView.vue` + `SubscriptionPlanTable`,CRUD + 刷新余额,路由 `/subscriptions` | ⚠️ 独立旧实现 `subscriptions/SubscriptionTab.vue`(与 Web 两套代码) |\n| 渠道中心 | ✅ `ChannelOrchestration.vue`(六态 `ChannelHealthBadge`、来源 chips、`autoManaged` 图标齐备) | ❌ 未同步 |\n| 管理面板 | ⚠️ `AutopilotModePanel.vue` 仅暴露 mode/killSwitch/costPreference | ❌ 无 |\n| 驾驶舱 | ✅ `CockpitView.vue`(观测型总览:健康统计+余额汇总+渠道推荐) | ⚠️ `cockpit` tab 渲染的是会话 Dashboard,职责不同 |\n\n**与本设计意图的关系**(对齐用户「自动调度为主、废旧手动驾驶舱」的诉求):\n- **旧的「手动调度驾驶舱」在 Web 端已不存在**。`CockpitView` 已是 §8.7 定义的观测型总览,只回答健康/省钱/风险/待办四问题,不做渠道配置编辑,符合「程序自动选、人只观测+有限干预」的方向。无需再废除,只需继续收敛为纯观测。\n- **统一提交入口已存在**(§8.4 快速添加 + §8.5 订阅中心),new-api 集成(§8.5.1)在此之上把「填 baseURL+key」进一步自动化。\n\n**前端演进待办**(本设计新增/明确):\n1. **补 `ManualRoutingIntent` 前端**:这是用户要的「有限偏向性干预」,后端已有、两个前端均未实现(§8.3 试用意图入口尚缺)。\n2. **Desktop 前端对齐 Web**:健康中心/渠道自动托管/管理面板/观测型驾驶舱在 Desktop 几乎空白,需同步或复用 Web 组件,避免长期维护两套。\n3. **管理面板补字段**:暴露 `l2ProbeEnabled`、探测预算、new-api/多来源订阅 adapter 开关(§8.5.2)等,当前只有 3 个字段。\n4. **订阅中心接 §8.5.1/§8.5.2**:新增「new-api」订阅类型与 Provider Adapter 能力矩阵驱动的动态表单。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.5.1 new-api 订阅集成(统一账号接入)\n\n**目标**:用户在订阅中心手填一个 new-api(及其 fork)账号的「系统访问令牌」即可,CCX 自动用该令牌创建代理专用 key、拉取分组倍率、查询额度余额,并把「站点 baseURL + 新建 key」自动落成一个上游渠道,交给 Autopilot 探测与调度。\n\n**范围约束**:\n- 只使用 new-api 用户在「个人设置」生成的 **系统访问令牌(access_token)** 一种凭据;令牌用于 API 调用的身份验证,属敏感数据,须妥善加密存储。\n- **不做** cookie 登录 + 2FA + 导出后台渠道明文 key 的管理员迁移路径(all-api-hub 的 newApiSession 能力),本设计不涉及。\n- new-api 归入 `OriginType=relay`、`OriginTier=second`(中转站),信任等级不因自动化提升;实时质量仍由运行画像决定。\n- 认证令牌像其他订阅凭据一样由用户手动录入,CCX 不代替用户在 new-api 侧注册或登录。\n\n**认证与凭据模型**:\n\nnew-api family 的管理接口鉴权(依据 all-api-hub 传输层实现):\n\n```text\nAuthorization: Bearer # 系统访问令牌;raw 模式可去掉 Bearer 前缀\nNew-API-User: # 当前用户 ID(必带)\nUser-id: # fork 兼容回退(Veloera/voapi/Super-API 等)\nContent-Type: application/json\n```\n\n统一响应信封:`{ \"success\": bool, \"data\": T, \"message\": string }`;解析时先判 `success`,再取 `data`。\n\n**SubscriptionProfile 扩展**:\n\n在 §3.2.2 的 `SubscriptionProfile` 基础上,为可自动接入的订阅新增 provider 集成字段(沿用「默认关闭 + 显式 opt-in」守则):\n\n```go\n// SubscriptionProfile 新增字段(复用 Phase 4 Item 6 的 SubscriptionAutoRefresh 开关)\ntype SubscriptionIntegration struct {\n Kind string `json:\"kind\"` // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | ...\n BaseURL string `json:\"baseUrl\"` // new-api 站点地址(同时作为新建渠道的上游 baseURL)\n AccessToken string `json:\"-\"` // 系统访问令牌;加密存储,不出 API 响应\n UserID string `json:\"userId\"` // New-API-User 头所需\n AuthTokenMode string `json:\"authTokenMode,omitempty\"` // \"bearer\"(默认) | \"raw\"\n // 自动建 key 的模板\n ProvisionKeyName string `json:\"provisionKeyName,omitempty\"` // 默认 \"ccx-autopilot\"\n ProvisionGroup string `json:\"provisionGroup,omitempty\"` // 建 key 时指定分组,空=默认分组\n ProvisionModels []string `json:\"provisionModels,omitempty\"` // model_limits,空=不限制\n // 自动同步产物(回填,只读展示)\n ProvisionedTokenID int `json:\"provisionedTokenId,omitempty\"`\n LinkedChannelUID string `json:\"linkedChannelUid,omitempty\"`\n}\n```\n\n`AccessToken` 必须与现有敏感字段一样走加密存储,序列化到前端时脱敏(仅显示尾部若干位)。\n\n**new-api 接口清单**(本集成实际调用的子集,路径以主线 new-api 为准,字段名保持与上游一致):\n\n| 用途 | 方法 | 路径 | 请求体 | 响应关键字段 |\n|------|------|------|--------|--------------|\n| 校验令牌 + 查用户/余额 | GET | `/api/user/self` | — | `data.id`、`data.username`、`data.quota`(剩余额度)、`data.used_quota` |\n| 拉分组倍率 | GET | `/api/user/self/groups` | — | `data: { : { desc, ratio } }` |\n| 站点全部分组 | GET | `/api/group` | — | `data: string[]` |\n| 账号可用模型 | GET | `/api/user/models` | — | `data: string[]` |\n| 创建代理 key | POST | `/api/token/` | `CreateTokenRequest`(见下) | `data.key` / `success` |\n| key 列表 | GET | `/api/token/?p={p}&size={n}` | — | `data.items[]`(含 `id/key/name/status/remain_quota`) |\n| 更新 key | PUT | `/api/token/` | `{ ...token, id }` | `success` |\n| 删除 key | DELETE | `/api/token/{id}` | — | `success` |\n\n`CreateTokenRequest`:\n\n```json\n{\n \"name\": \"ccx-autopilot\",\n \"remain_quota\": 0,\n \"expired_time\": -1,\n \"unlimited_quota\": true,\n \"model_limits_enabled\": false,\n \"model_limits\": \"\",\n \"allow_ips\": \"\",\n \"group\": \"\"\n}\n```\n\n`quota` 是 new-api 内部额度单位(非美元),换算比例读 `GET /api/status` 的 `price` / `PaymentUSDRate`(无鉴权),或允许用户在订阅中手填换算率;MVP 直接以 quota 原值展示 + 站点换算率估算余额。\n\n**接入流程**(订阅中心「新增订阅 → 类型选 new-api」):\n\n```text\n1. 用户输入:站点 baseURL + 系统访问令牌(access_token) + userId\n (userId 可选自动获取:先用令牌调 /api/user/self 拿 data.id 回填)\n2. 校验:GET /api/user/self\n ├─ success=false / 401 → 令牌无效或 userId 不匹配,报错停止\n └─ 成功 → 记录 username、quota,创建 SubscriptionProfile(provider=new_api, originTier=second)\n3. 拉分组:GET /api/user/self/groups → 写入 GroupMultipliers({group: ratio})\n GET /api/user/models → 记录账号可用模型,供渠道 supportedModels 参考\n4. 建 key:POST /api/token/(按 ProvisionKeyName/Group/Models 模板)\n ├─ 已存在同名 ccx-autopilot key → 复用其 id,不重复创建(列表比对 name)\n └─ 新建成功 → 回填 ProvisionedTokenID,取 data.key 作为渠道 apiKey\n5. 自动建渠道:用 baseURL + 新建 key 创建一个上游渠道\n ├─ channelKind 由用户选择或按站点探测(messages/chat/responses/gemini)\n ├─ status=unknown,autoManaged=true,OriginType=relay\n ├─ 建立 SubscriptionUID → ChannelUID 链接(回填 LinkedChannelUID)\n └─ 触发 §8.4 的 Discovery + 能力测试标准流程\n6. 完成:订阅卡片显示余额、分组倍率、已绑定渠道、探测进度\n```\n\n关键点:new-api 集成**不额外造探测逻辑**,第 5 步之后完全复用现有 §8.4「添加并探测」链路;集成层只负责「令牌 → key + 分组 + 余额 + baseURL」的自动填充,把手填 baseURL/key 的动作自动化掉。\n\n**刷新与同步**(复用 `POST /api/subscriptions/{uid}/refresh`):\n\n| 触发 | 动作 |\n|------|------|\n| 手动刷新余额 | `GET /api/user/self` 更新 `Balance` + `BalanceUpdatedAt` |\n| 定时刷新(可选,opt-in) | 复用 Phase 4 Item 6 `SubscriptionAutoRefresh`;new-api 作为 `relay` provider adapter 纳入,默认关闭 |\n| 分组倍率变更 | 刷新时对比 `/api/user/self/groups`,`GroupMultipliers` 变化 → 更新并提示「套餐倍率已变」 |\n| key 失效检测 | 渠道侧探测到该 key 全 401 → 订阅卡片提示「key 可能已删除/禁用」,提供「重新建 key」一键动作(重跑第 4-5 步,复用旧渠道,只换 apiKey) |\n| 可用模型变更 | `/api/user/models` 结果哈希变化 → 触发对应渠道重新 Discovery |\n\n**安全与边界**:\n- `access_token` 权限等同 new-api 账户,须加密存储、脱敏展示、禁止写入日志;刷新失败连续 N 次自动停用该订阅的自动刷新,只保留手动。\n- 自动建 key 默认 `unlimited_quota=true` 但可由用户改为限额模板;建 key 是有副作用的写操作,须在接入前明确告知用户「将在 new-api 侧创建一个名为 ccx-autopilot 的令牌」。\n- new-api 是 `OriginTier=second`,**不得**用作隐私敏感的 routing advisor / classifier(遵守 §3.2.1 约束)。\n- fork 兼容:请求同时带 `New-API-User` 与 `User-id` 头;响应信封字段名(`success/data/message`)在部分 fork 可能不同,adapter 需容错解析,失败时回退手动录入。\n- 一个 new-api 订阅默认只自动建/绑定一个渠道;用户如需该站多协议渠道,可在同订阅下手动增建,共用同一 key。\n\n### 8.5.2 订阅 Provider Adapter 框架(多来源余额与接入)\n\n§8.5.1 的 new-api 集成不是特例,而是「订阅 Provider Adapter」框架的一个实现。Phase 4 Item 6 已落地 openai/anthropic/google 的余额刷新 adapter,本框架把它抽象成统一接口,让 new-api 及后续来源(GitHub Copilot、Codex、Nvidia、MiniMax、DeepSeek 等)按相同契约接入,避免每加一个来源就散落一套逻辑(DRY)。\n\n**统一接口**:\n\n```go\n// backend-go/internal/autopilot/subscription/adapter.go\ntype ProviderAdapter interface {\n Kind() string // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | \"copilot\" | \"deepseek\" | ...\n\n // 校验凭据,返回账号身份(用户名/账户标识);失败即凭据无效\n Verify(ctx context.Context, cred Credential) (AccountInfo, error)\n // 查余额/额度;不支持返回 ErrUnsupported,由 UI 降级为手动录入\n FetchBalance(ctx context.Context, cred Credential) (Balance, error)\n // 拉分组倍率;仅 new-api 类中转有,官方直连返回 ErrUnsupported\n FetchGroups(ctx context.Context, cred Credential) (map[string]GroupRatio, error)\n // 自动创建代理 key;官方直连通常不支持(key 即用户 API key),返回 ErrUnsupported\n ProvisionKey(ctx context.Context, cred Credential, tmpl KeyTemplate) (ProvisionedKey, error)\n // 可用模型列表;用于渠道 supportedModels 参考\n FetchModels(ctx context.Context, cred Credential) ([]string, error)\n}\n```\n\n每个能力独立、可选:adapter 只实现自己支持的部分,不支持的统一返回 `ErrUnsupported`(接口隔离原则),UI 据此隐藏/降级对应入口。所有 adapter 共用 `SubscriptionAutoRefresh` 的 opt-in 开关与 `Validate()` 兜底,与 Phase 4 现有防御模式一致。\n\n**能力矩阵**(✅ 支持 / ⚠️ 部分或需确认 / ❌ 不支持,走手动):\n\n| 来源 | Kind | 验证 | 查余额 | 拉分组 | 自动建 key | OriginTier |\n|------|------|:----:|:------:|:------:|:----------:|------------|\n| new-api family | `new_api` | ✅ | ✅ quota | ✅ | ✅ | second |\n| OpenAI 官方 | `openai` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Anthropic 官方 | `anthropic` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Google 官方 | `google` | ✅ | ⚠️ | ❌ | ❌ | first |\n| GitHub Copilot | `copilot` | ⚠️ token | ❌ 无额度概念 | ❌ | ❌ | first |\n| Codex plan | `codex` | ⚠️ | ⚠️ 用量窗口 | ❌ | ❌ | first |\n| Nvidia NIM | `nvidia` | ✅ | ⚠️ credits | ❌ | ❌ | first |\n| MiniMax | `minimax` | ✅ | ⚠️ | ❌ | ❌ | first |\n| DeepSeek | `deepseek` | ✅ | ✅ balance API | ❌ | ❌ | first |\n\n说明:官方直连来源普遍**不支持自动建 key**(用户的 API key 本身就是凭据,直接作为渠道 apiKey 录入即可)、也**没有分组倍率**(价格按官方定价表)。它们的 adapter 价值集中在「验证 + 查余额/用量窗口」,把 §3.2.4 UsageWindow 的官方用量 API 来源坐实。DeepSeek 有独立余额查询端点,可完整支持余额。Copilot 属订阅制、无 token 余额概念,只做验证 + 用量提示。\n\n**接入分层**(YAGNI:不强求一次做全,按价值排期):\n\n```text\nP0(本轮):new_api(完整:验证+余额+分组+建key+自动建渠道)\n —— 覆盖用户最主要的中转站场景\nP1:deepseek(验证+余额)、官方三家余额已在 Phase 4 完成 → 补 UsageWindow 对齐\nP2:nvidia / minimax(验证+credits/余额)\nP3:copilot / codex(验证+用量窗口提示,无余额)\n```\n\n每个来源的具体端点在实现时于 `adapter.go` 各 provider 文件内落地,本设计只固定接口契约与能力矩阵,避免文档与上游 API 变动强耦合。新增来源 = 实现 `ProviderAdapter` + 在能力矩阵登记,不改框架。\n\n**能力缺失与降级**:\n- adapter 任一能力返回 `ErrUnsupported` → 订阅中心对应字段回退为手动录入(余额手填、倍率手填),不报错、不阻塞接入。\n- adapter 验证失败/超时 → 订阅可仍以「手动模式」创建,仅失去自动刷新与自动建 key,其余流程(手填 baseURL+key 建渠道)不受影响。\n- 官方来源无「自动建 key」时,接入流程退化为 §8.4 标准快速添加:用户填 API key,adapter 只补验证与余额。\n- 所有自动刷新遵守 `SubscriptionAutoRefresh` 默认关闭;连续失败自动停用自动刷新并在驾驶舱「人工待办」提示。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3,\n \"providerTimePricing\": {\n \"deepseek\": {\n \"effectiveFrom\": \"2026-07-20T00:00:00+08:00\",\n \"timeZone\": \"Asia/Shanghai\",\n \"peakMultiplier\": 2,\n \"peakWindows\": [\n { \"start\": \"09:00\", \"end\": \"12:00\" },\n { \"start\": \"14:00\", \"end\": \"18:00\" }\n ]\n }\n }\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n`providerTimePricing.effectiveFrom` 使用 RFC3339;修改该值即可调整激活时间,设为空字符串则停用对应规则。\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**Phase 3A 状态(2026-07-09)**:低风险、不改真实路由语义的子集已完成并接入真实链路——`Manager.collectAll()` 新旧画像对比产出变更事件(`DetectProfileChanges`)、`ProfileChangelogStore`(环形内存 + SQLite,30 天机会性清理)、`EventHub` 内存 pub/sub、`GET /api/health-center/changelog`(历史)+ `GET /api/health-center/events`(WebSocket 实时推送)、`AutoDiscoveryRunner` 发布 `discovery_completed`/`auto_mapping_applied`、前端 `ProfileChangelogTimeline.vue` 接入健康中心页并支持断线自动重连。均为只读展示,未修改 `SmartRouter`/`EndpointAttemptPolicy` 调度逻辑。落地时顺带发现并修复一个真实缺口:浏览器原生 WebSocket 无法设自定义 header,为此给 `middleware.getAPIKey` 增加了 `Sec-WebSocket-Protocol` 鉴权回退(业界标准做法,仅新增一个 key 来源,不降低现有校验)。\n\n**Phase 3B(已全部完成,2026-07-09)**:模型自动映射、自动恢复探测、晋升/降级机制会直接改变真实调度决策,风险明显高于 3A,四个子项已分批全部完成。§12.2 P1.5 的 SQLite schema version / 版本化 migration / `OriginType`&`OriginTier` 旧配置 backfill / `killSwitch`+按 task class/channel disable **已于 2026-07-09 补强完成**(详见 §12.2 P1.5)。**Phase 3B-1(自动恢复探测补全)已于 2026-07-09 完成**:修复了 L2 探测子系统里此前从未打通的两个真实 bug——① `collectAll()` 每轮把新构造的画像整行 Upsert,会把 `ProbeWorker` 刚写入的 `LastProbeAt`/`ProbeSuccess`/`ConsecutiveProbeSuccess` 等字段清零,导致 6 小时探测冷却期形同虚设(修复:`carryForwardProbeFields` 在 Upsert 前搬运 Probe* 字段,不影响 L1 诊断字段的现有覆盖逻辑);② `extractRawAPIKey` 是返回 `KeyMask` 掩码值的占位 stub,探测请求实际带的是假 key(修复:新增 `APIKeyResolver` 回调 + `Manager.ResolveAPIKey`,通过遍历配置里对应渠道的 `APIKeys` 逐个计算 `KeyHashFromAPIKey` 比对来还原明文;resolver 未注入或未命中时 fail-open 跳过探测且不消耗预算)。同时补齐了 `degraded/limited → healthy` 的连续成功恢复路径(新增 `KeyEndpointProfile.ConsecutiveProbeSuccess` 计数器 + `HealthCheckConfig.ProbeRecoveryThreshold`,默认连续 2 次探测成功才恢复,避免单次探测噪声导致状态抖动;探测失败计数器清零)。**Phase 3B-3(StabilityTier 晋降级滞后)已于 2026-07-09 完成**(提交 664d3fff):在原本每轮从瞬时 1h 窗口重算的无记忆 `StabilityTier` 上加一层连续窗口滞后——新增 `KeyEndpointProfile.EffectiveStabilityTier`/`StabilityPendingTier`/`StabilityPendingStreak` 三字段 + 纯函数 `applyStabilityHysteresis`(对称阈值:连续 N 轮同值才切换生效档,`HealthCheckConfig.StabilityHysteresisWindows` 默认 2;首次画像直接采用,不等待阈值,避免新渠道卡在零值),`AggregateChannelProfile` 改读 `EffectiveStabilityTier`(空则回退 `StabilityTier`)。晋升/降级机制的数据模型仅限于 StabilityTier,不改变 QualityTier/SpeedTier/CostTier 语义。**Phase 3B-2(模型自动映射闭环)已于 2026-07-09 完成**(提交 0f02e988,随后修复一处 critical 集成 bug,提交 856372c4):新建 `ModelProfileStore`(SQLite,`model_profiles` 表)+ 扩展 `AutoDiscoveryRunner` 为每个发现模型写入 `ModelProfile`(仅 `AutoManaged` 渠道);新建 `ModelResolver` 实现 §5.4 五步逻辑(手动 `RedirectModel` 短路 → `GetModelProfiles` → `filterByCapabilityFloor` → `rankBySimilarity`);`scheduler` 包新增 `ModelSupportResolverFunc` hook(fail-open,未注入或返回不支持时严格回退到原 `ExplainModelSupport`);`endpoint_policy.go` 的 `resolveMappedModel`(此前是永不触发的死代码——`profile.ModelMapping` 从未被写入)现调用 `ModelResolver`;`handlers/common` 在 `RedirectModel` 之后应用 `MappedModel` 覆盖请求体,并按 `EchoMappedModel` 配置回显 `X-CCX-Mapped-Model`/`X-CCX-Original-Model`/`X-CCX-Mapping-Source` 响应头。三条件安全门控:`ModelMapping.AutoResolve==true`(**默认由死配置的 true 改为 false**)AND `RoutingMode ∈ {assist, auto}` AND 无 `KillSwitch`,三者任一不满足则所有新增代码路径行为与今天字节级一致;且仅 `AutoManaged` 渠道触发,手动配置渠道完全不受影响。对抗性复核发现并修复一个真实 critical bug:`scoreEndpointForKey` 用空 `channelUID` 计算 `EndpointUID` 并写入 `modelByUID`,而 `handlers` 层用真实 `channelUID` 查 `modelByUID`,两者哈希永不相等导致 `MappedModel` 在 key 级调度路径上完全查不到(提交 856372c4 修复:命中画像后改用 `profile.EndpointUID`,新增回归测试锁定该不变量)。至此 Phase 3B 四个子项(自动恢复探测、StabilityTier 滞后、模型自动映射、active filter 前置判定)全部完成;SLO regression 自动回滚仍是独立子系统,留待后续单独立项。\n\n**范围**:\n- [x] 运行时指标驱动画像实时更新(Phase 3A:`collectAll()` 每轮 diff + changelog + WebSocket 推送)\n- [x] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)—— Phase 3B-2(2026-07-09 完成,提交 0f02e988 + 856372c4)\n- [x] active model filter / context filter 支持自动映射前置判定 —— Phase 3B-2:`scheduler.ModelSupportResolverFunc` fail-open 接入 `select.go`\n- [x] 自动恢复探测(limited/dead → healthy)—— Phase 3B-1(2026-07-09 完成):`APIKeyResolver` + `carryForwardProbeFields` + `ConsecutiveProbeSuccess`/`ProbeRecoveryThreshold`\n- [x] 晋升/降级机制(连续成功→升级,连续失败→降级)—— Phase 3B-3(2026-07-09 完成,提交 664d3fff):`StabilityTier` 连续窗口滞后(`EffectiveStabilityTier`),暂不含 QualityTier/SpeedTier/CostTier\n- [x] WebSocket 推送画像变更事件(Phase 3A:`GET /api/health-center/events`)\n- [x] 前端画像变更历史/时间线(Phase 3A:`ProfileChangelogTimeline.vue`)\n\n**预估工期**:2-3 周(3A、3B-1、3B-2、3B-3 均已完成;advisor 与全局 auto 的 SLO 回滚均已补齐)\n\n> 状态校正(2026-07-17):上方 Phase 3B 历史说明中的“SLO regression 自动回滚待单独立项”已完成;advisor 回滚与全局 auto 上线闸门/自动降级是两个独立保护层。\n\n### Phase 4:高级特性\n\n**Phase 4 状态(2026-07-09,全部完成)**:8 项全部实现并接入真实链路,均遵循\"默认关闭 + 显式 opt-in\"安全守则。\n\n**范围**:\n- [x] 多维度标签系统扩展(用户自定义标签)——Item 1\n- [x] 成本报表:按用户/模型/渠道/key 统计真实有效成本——Item 2(新增 `proxyKeyMask` 身份贯穿链路:`middleware.GetProxyKeyMask` → `ChannelLog`/`RequestRecord` → SQLite `proxy_key_mask` 列,供 Item 4 复用)\n- [x] Trusted advisor SLO regression 自动回滚——Item 3(默认关闭;`SLORollbackConfig`,连续 degrading 窗口数默认 3,不做自动恢复以避免震荡)\n- [x] 渠道推荐:根据使用模式推荐新渠道——Item 4\n- [x] 批量渠道管理(导入/导出/模板)——Item 5\n- [x] 订阅中心 provider adapter:可选自动刷新余额/套餐状态——Item 6(默认关闭;仅 openai/anthropic/google 且显式填写 BillingAPIKey 的订阅生效,中转/公益渠道继续手动维护)\n- [x] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板——Item 7\n- [x] A/B 测试:低比例统计抽样双发——Item 8(默认关闭,默认 1% 抽样 + 每小时 60 次硬预算;主请求路径不受影响,影子请求在主响应返回后异步发起)\n\n**关键实现说明**:\n- Item 3/6/8 均在 `AutopilotRoutingConfig` 新增独立配置块(`SLORollback`/`SubscriptionAutoRefresh`/`ABTest`),`Validate()` 统一做负值/越界兜底,保持与既有字段一致的防御模式。\n- Item 4 直接复用 Item 2 的 `proxyKeyMask` 身份标记,未重新发明身份识别机制。\n- **全局 auto 安全闸门(2026-07-17)**:请求终态按 15 分钟窗口无偏聚合,详细 trace 继续抽样;管理 API 只在 shadow/assist 已积累至少 500 个有效请求、24 小时连续观测且成功率/fallback/fail-open/p95 均达标时允许切换 `auto`。后台连续检测三个已完成 auto 窗口,相对最近七天安全模式基线持续恶化时,持久化降级到 `assist` 并记录 `autopilot_auto_safety_events`,不自动恢复。\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- ~~SQLite schema version 和幂等 migration。~~ **已完成**:`ensureSchemaVersion` 基于 `PRAGMA user_version`,当前 7 张表定版为 v1 基线;库版本高于代码版本时返回 error(fail-closed,不做任何写操作),交给下游 fail-open 分支接管。\n- ~~旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。~~ **已完成**:`ensureOriginBackfill` 只补 `\"unknown\"`,不做任何基于 URL/名称的猜测推断;已接线到 `Profiler.DeriveEndpointProfile`,`BreakTieByOriginTier` 现在能吃到真实数据(此前一直是 fallback 的 `unknown`)。\n- ~~profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。~~ **已确认满足并测试锁定**:`main.go` 现有结构本就是 fail-open(`NewProfileStore`/`NewManager` 出错只打日志、`autopilotManager` 保持 `nil`,下游全部有 `nil` 守卫),本次新增 `schema_migration_test.go` 用测试锁定这条不变量。\n- ~~全局 `killSwitch`、按 task class disable、按 channel disable。~~ **已完成**:新增 `AutopilotRoutingConfig.DisabledTaskClasses`/`DisabledChannelUIDs`;`CandidateFilterFor`/`executeFilter`/`BuildPlan` 三处一致接入,`smart_router_invariant_test.go` 覆盖\"task class 禁用后回退默认调度\"\"channel 禁用后不出现在候选里\"\"dry-run 与真实路径行为一致\"三条不变量。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。**待实现**:`TrustedRoutingAdvisor.promotionMode` 目前是 `manual`,shadow→candidate→active 已有人工审查关卡;自动回滚是独立的新子系统(需要基线捕获窗口 + 实时对比 + 阈值判定),留待下一轮单独设计落地。\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | shadow 样本/准确率/critical misroute 门槛;active 后 SLO regression 自动 rollback;全局 kill switch |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "2a8bcfc193af0d67b777323a15297d090042a68f": {"t": 1785759347.6223254, "n": 158643, "h": "2d321f5ad6222e47", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.2.3 同订阅多 Key:能力共享与配额负载均衡\n\n**问题**:官方订阅 plan(Claude Max、Codex plan 等)允许用户持有多个 key,或购买多份相同套餐。若每个 endpoint 独立探测与配置,探测预算被浪费、配置重复;请求量大时还需要在同 plan 的多个 key 之间按官方 RPM 限速自动负载均衡,而不是打满第一个 key 才溢出到下一个。\n\n**能力检测去重(同一套餐只需一套能力检测和配置)**:\n\n```text\nSubscriptionUID 相同的 endpoint 共享一份「订阅级能力画像」:\n 模型列表 / vision / tool / reasoning 支持 / 协议兼容开关 → 只探测一次,\n 存在 SubscriptionProfile.SharedCapability,endpoint 画像以引用方式继承。\n\n仍然 per-endpoint 维护的:健康状态、延迟、熔断、用量窗口\n (key 可能被单独封禁/限速,能力相同不代表运行态相同)\n\n继承失效条件:某 key 实测模型列表哈希与订阅级不一致(GroupChangeDetector 信号)\n → 该 key 脱离继承、独立探测,并在 UI 标记「与套餐能力不一致」警告。\n```\n\n**配额负载均衡(Phase 1 shadow 展示,Phase 2 生效)**:\n\n```text\n每 key 维护配额余量视图:\n remainingRPM = officialRPM − 当前窗口已用\n officialRPM 来源优先级:内置 manifest > 用户配置 > RateLimitDiscoverer 建议\n\n同订阅多 key 时按「剩余余量加权」分配请求(余量大者优先、\n窗口最早重置者兜底),替代顺序打满:\n 仅当渠道 autoManaged 且同一 SubscriptionUID 下 key 数 > 1 时启用;\n 否则保持现有 sequential/random key 顺序策略不变。\n\n某 key 的窗口配额耗尽 → 临时降权并切到同订阅其他 key,\n不熔断整个渠道(这是配额耗尽,不是故障)。\n```\n\n### 3.2.4 订阅用量窗口 (Usage Windows)\n\n部分官方 plan 提供 5h / 日 / 周 / 月等窗口的用量查询与限制(如 Claude 订阅的 5h 滚动窗口 + 周配额)。在 key 的位置展示这些窗口,帮助用户掌握使用情况:\n\n```go\ntype UsageWindow struct {\n Window string `json:\"window\"` // \"5h\" | \"day\" | \"week\" | \"month\"\n Used float64 `json:\"used\"` // 已用量\n Limit float64 `json:\"limit\"` // 上限,0 = 未知\n Unit string `json:\"unit\"` // requests | tokens | credits | percent\n ResetsAt time.Time `json:\"resetsAt\"` // 窗口重置时间\n Source string `json:\"source\"` // official_api | response_header | local_metering\n FetchedAt time.Time `json:\"fetchedAt\"`\n}\n// KeyEndpointProfile 与 SubscriptionProfile 均增加 UsageWindows []UsageWindow\n```\n\n数据来源优先级:\n\n```text\n1. 官方用量 API:有 provider adapter 的 plan 主动拉取(§Phase 4 订阅 adapter 的可提前子集)\n2. 响应头:anthropic-ratelimit-* / x-ratelimit-* 等(复用 RateLimitDiscoverer 的信号 tap)\n3. 本地计量兜底:CCX 按 key 累计请求数/token(误差来自窗口起点对齐,标注 Source=local_metering)\n```\n\nUI 要求:key 行内显示主窗口(5h)用量进度条,悬浮展开全部窗口;用量 >80% 黄色预警、超限红色并显示预计重置时间。调度联动(Phase 2):窗口余量进入配额负载均衡权重,窗口耗尽的 key 临时降权而非熔断。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n#### 4.2.1 内置模型清单 (Builtin Models Manifest)\n\n**问题**:部分官方订阅 plan 的入口没有正确的 models 接口——要么不存在(404)、要么需要额外权限、要么返回与订阅实际可用模型不符的列表(典型:Claude 订阅 OAuth 入口、Codex plan 入口、部分官方企业网关)。对这些 baseURL 做模型发现会失败或产出错误画像。\n\n**方案**:CCX 内置「已知官方 baseURL → 模型清单」映射表,命中时直接返回内置内容,完全跳过上游 models 接口:\n\n```go\n// backend-go/internal/config/builtin_models_manifest.go(生成式,随模型注册表更新)\ntype BuiltinModelsManifest struct {\n BaseURLPattern string `json:\"baseUrlPattern\"` // 精确 host 或前缀匹配,如 \"api.anthropic.com\"\n ServiceType string `json:\"serviceType\"` // messages/responses/…\n PlanHint string `json:\"planHint,omitempty\"` // 订阅类型提示(如 \"claude_subscription\")\n ModelIDs []string `json:\"modelIds\"` // 该入口实际可用的模型清单\n DisableProbe bool `json:\"disableProbe\"` // true 时 Discovery 跳过 GET /v1/models\n}\n```\n\n**两个生效位置**:\n\n```text\n1. Discovery(§4.2 流程第 2 步之前):\n baseURL 命中 manifest 且 DisableProbe=true\n → 跳过 GET /v1/models,直接用 ModelIDs 生成 ModelProfile[]\n → ProfileSource = \"builtin_manifest\",不再因 models 接口失败而误判 misconfigured\n\n2. /v1/models 代理响应(现有 Models API):\n 客户端向 CCX 请求 /v1/models 时,聚合结果中该渠道的贡献\n 直接取内置清单,不向上游发起 models 请求\n (渠道显式配置了 supportedModels 时仍以用户配置优先)\n```\n\n**维护与覆盖顺序**:\n\n```text\n渠道显式 supportedModels(用户手动) > 内置 manifest > 上游 models 接口实探 > 内置候选模型回退\n```\n\nmanifest 与模型注册表同源生成(`generated_model_registry.go` 同级),新模型发布时随注册表一起更新;HealthAnalyzer 对命中 manifest 的 endpoint 不将「models 接口 404/403」计入 misconfigured 信号。\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20;assist 下 dead/misconfigured 额外 -100 并保留在队尾\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法细分时进入 `general`;有规范 Knowledge 基准则使用其软评分,缺少直接证据时仍取 0.5 中性值。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n**手动 L3 第一版状态(2026-07-15)**:已实现 `pq-v1-20260715` 固定 JSON canary,覆盖\nMessages、Chat Completions、Responses 与 Gemini 上游协议。调用方只能指定 `endpointUid`、\n`modelId` 和 1~3 次重复数,不能注入 prompt。评分仍按 40% 完整性、30% 语义正确性、\n15% 严格 JSON 格式、15% 延迟执行;第一版使用非流式完整响应延迟作为首 token 延迟代理,\n后续引入流式采样时再替换该维度。单次有效样本 confidence=0.6,最多三次提升到 0.8;\n失败样本按 0 分计入均值,全部失败不写画像。\n\n安全边界:仅管理 API 手动触发,不启动自动 worker;当前进程按 UTC 日设置 12 次硬预算,\n请求前原子预留,单次 90 秒超时、响应体上限 64 KiB。同一 endpoint×model 不并发执行;\n单次请求最多允许 2048 个输出 token,以容纳推理模型的隐藏思考并限制最坏额度消耗。\n对声明可控推理的模型固定采用最低可用 effort;Claude 兼容端点若只暴露\n`reasoning_content` 而未声明 thinking 模式,则与 capability-test 一致显式关闭 thinking。\n原始模型输出和明文 Key 既不落盘也不返回,只保存分数、canary 版本、置信度和脱敏判定。\n`user_feedback` 仍高于 `probe`,自动发现重建 ModelProfile 时必须保留既有质量证据。\n跨进程共享预算与 LLM-as-judge/语义向量评分仍属于后续阶段。\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设 + 自定义\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n| `custom` | 用户自定义 | 用户自定义 | 高级用户微调,范围 0.0 ~ 3.0 |\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法细分的通用任务;缺少基准证据时中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general;有规范 Knowledge 基准时使用其软评分,缺少直接证据时取 0.5 中性。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? canonicalBenchmark[model][domain] * providerQualityFactor\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 规范模型基准 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n#### 5.7.4 规范模型能力基准\n\n`shared/model-registry/ccx_model_registry.json` 的 `benchmarkProfiles` 独立保存规范模型能力上界,不与 `upstreamCapabilities` 混合。后者仍是 context、vision、tool calls 等硬能力的事实源;benchmark 只参与 Autopilot 软评分和 trace 解释,绝不改变 `supportedModels` 或 CapabilityFloor。\n\n```text\nEffectiveCapability[domain]\n = CanonicalCeiling[model, domain]\n × ProviderQualityFactor[endpoint]\n\nProviderQualityFactor = 1 - confidence × (1 - providerQualityScore)\n无渠道质量证据或 confidence<0.5 时 factor=1;可信证据只能从规范模型上界向下折算。\n```\n\n基准数据必须保留领域向量、来源、核验日期、lane、共享结果数和可比类别覆盖率,不能只保存站点 Overall。当前 BenchLM 接入为 `provisional`:Claude Opus 4.8、GPT-5.6 Terra、GPT-5.6 Sol 各有 30 个共享结果,8 类中 5 类可比,核验日期为 2026-07-13。\n\n领域映射:Knowledge → `general`,Math → `reasoning`,Coding → `coding` / `code_review`,Agentic → `agentic`,Multimodal → `aesthetics_ui`(仅弱代理,解释置信度减半)。Writing / Translation 没有直接类别证据时继续回退家族种子或 0.5。BenchLM 的 cohort 归一化、缺失项插补和外部校准决定了 Overall 不是绝对能力值,因此路由只消费可映射的原始类别分。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**请求意图边界(ModelRoutingPolicy)**\n\n跨模型替代不是所有模型的默认能力,只对白名单客户端入口开放:\n\n```text\nmessages:\n fable / opus / sonnet / haiku 及对应 claude-* 完整模型名 → adaptive\nresponses:\n gpt-5.6* / gpt-5.5 / gpt-5.4* / codex-auto-review → adaptive\n其他协议或模型:\n exact-only,只能在探测到相同规范化模型 ID 的 endpoint 之间调度\n```\n\n`adaptive` 允许在满足 Capability Floor 后选择 GLM、MiMo 等其他模型族;`exact-only` 禁止跨模型替代。用户显式配置的 `modelMapping` 仍优先于该策略。调度预览和 endpoint 实际解析必须调用同一分类函数,避免诊断结果与真实请求分叉。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ModelProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选优\n // 上下文窗口只负责准入,不对更大的窗口扣分。\n // 排序优先级:\n // a. 模型质量档越高越优先\n // b. 带置信度折算的供应商实测质量\n // c. 探测延迟\n // d. 同渠道/provider 的模型相对消耗倍率\n // 倍率相同或缺失时再比较公开模型成本\n // e. 同模型族与 model ID 仅作为确定性兜底\n best := rankEligibleModels(eligible, requestModel)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n同渠道套餐倍率与公开 USD/Token 价格是不同单位,禁止直接混算。模型解析器先比较 provider\n提供的相对消耗倍率;只有双方倍率相同,或双方均无倍率时,才回退到统一输入/输出规模下的\n公开价格。优云智算当前内置倍率如下(数字越小越省,`glm-5.2` 的 2 次为限时值):\n\n| 模型 | 每次调用套餐消耗 |\n|------|-----------------:|\n| `glm-5.2` | 2 |\n| `glm-5.1` | 6 |\n| `MiniMax-M2.7` | 2 |\n| `kimi-k2.6` | 5 |\n| `deepseek-ai/DeepSeek-V3.2` | 1 |\n| `deepseek-v4-flash` | 1 |\n\n该倍率只在模型质量档、供应商实测质量和探测延迟相同后参与排序。因此 `glm-5.2` 即使消耗\n2 次,也仍会在质量档高于 DeepSeek 候选时优先;V3.2 与 V4 Flash 同为 1 次时,再由公开\n价格及最终确定性规则区分。静态 `modelMapping` 仅保留为用户显式覆盖,不承担自动替代策略。\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - autoManaged 的空 supportedModels 不表示支持全部,必须由 endpoint ModelProfile 裁决\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n当 ModelResolver 已正常初始化且路由处于 `assist/auto + autoResolve=true` 时,autoManaged 渠道的画像拒绝是权威结果,调度器不得再 fail-open 到空 `supportedModels` 的兼容语义。Resolver 不可用、开关关闭或 `off/shadow` 时仍保留原有 fail-open,保证急停和降级路径不依赖画像库。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\nGET /api/health-center/provider-quality/budget → 查看本进程手动 L3 每日预算\nPOST /api/health-center/provider-quality/probe → 对指定 endpoint×model 执行固定质量 canary\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n#### 8.0.1 实现现状与演进方向(2026-07-09 核实)\n\n四界面在 **Web 前端(`frontend/src`)已大面积落地**,不是纯文档:\n\n| 界面 | Web 实现 | Desktop 实现 |\n|------|----------|--------------|\n| 健康中心 | ✅ `HealthCenterView.vue` + `HealthCenterStats`/`HealthChannelTable`/`HealthChannelDetail`/`ProfileChangelogTimeline`,路由 `/health` | ❌ 无 |\n| 订阅中心 | ✅ `SubscriptionsView.vue` + `SubscriptionPlanTable`,CRUD + 刷新余额,路由 `/subscriptions` | ⚠️ 独立旧实现 `subscriptions/SubscriptionTab.vue`(与 Web 两套代码) |\n| 渠道中心 | ✅ `ChannelOrchestration.vue`(六态 `ChannelHealthBadge`、来源 chips、`autoManaged` 图标齐备) | ❌ 未同步 |\n| 管理面板 | ⚠️ `AutopilotModePanel.vue` 仅暴露 mode/killSwitch/costPreference | ❌ 无 |\n| 驾驶舱 | ✅ `CockpitView.vue`(观测型总览:健康统计+余额汇总+渠道推荐) | ⚠️ `cockpit` tab 渲染的是会话 Dashboard,职责不同 |\n\n**与本设计意图的关系**(对齐用户「自动调度为主、废旧手动驾驶舱」的诉求):\n- **旧的「手动调度驾驶舱」在 Web 端已不存在**。`CockpitView` 已是 §8.7 定义的观测型总览,只回答健康/省钱/风险/待办四问题,不做渠道配置编辑,符合「程序自动选、人只观测+有限干预」的方向。无需再废除,只需继续收敛为纯观测。\n- **统一提交入口已存在**(§8.4 快速添加 + §8.5 订阅中心),new-api 集成(§8.5.1)在此之上把「填 baseURL+key」进一步自动化。\n\n**前端演进待办**(本设计新增/明确):\n1. **补 `ManualRoutingIntent` 前端**:这是用户要的「有限偏向性干预」,后端已有、两个前端均未实现(§8.3 试用意图入口尚缺)。\n2. **Desktop 前端对齐 Web**:健康中心/渠道自动托管/管理面板/观测型驾驶舱在 Desktop 几乎空白,需同步或复用 Web 组件,避免长期维护两套。\n3. **管理面板补字段**:暴露 `l2ProbeEnabled`、探测预算、new-api/多来源订阅 adapter 开关(§8.5.2)等,当前只有 3 个字段。\n4. **订阅中心接 §8.5.1/§8.5.2**:新增「new-api」订阅类型与 Provider Adapter 能力矩阵驱动的动态表单。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.5.1 new-api 订阅集成(统一账号接入)\n\n**目标**:用户在订阅中心手填一个 new-api(及其 fork)账号的「系统访问令牌」即可,CCX 自动用该令牌创建代理专用 key、拉取分组倍率、查询额度余额,并把「站点 baseURL + 新建 key」自动落成一个上游渠道,交给 Autopilot 探测与调度。\n\n**范围约束**:\n- 只使用 new-api 用户在「个人设置」生成的 **系统访问令牌(access_token)** 一种凭据;令牌用于 API 调用的身份验证,属敏感数据,须妥善加密存储。\n- **不做** cookie 登录 + 2FA + 导出后台渠道明文 key 的管理员迁移路径(all-api-hub 的 newApiSession 能力),本设计不涉及。\n- new-api 归入 `OriginType=relay`、`OriginTier=second`(中转站),信任等级不因自动化提升;实时质量仍由运行画像决定。\n- 认证令牌像其他订阅凭据一样由用户手动录入,CCX 不代替用户在 new-api 侧注册或登录。\n\n**认证与凭据模型**:\n\nnew-api family 的管理接口鉴权(依据 all-api-hub 传输层实现):\n\n```text\nAuthorization: Bearer # 系统访问令牌;raw 模式可去掉 Bearer 前缀\nNew-API-User: # 当前用户 ID(必带)\nUser-id: # fork 兼容回退(Veloera/voapi/Super-API 等)\nContent-Type: application/json\n```\n\n统一响应信封:`{ \"success\": bool, \"data\": T, \"message\": string }`;解析时先判 `success`,再取 `data`。\n\n**SubscriptionProfile 扩展**:\n\n在 §3.2.2 的 `SubscriptionProfile` 基础上,为可自动接入的订阅新增 provider 集成字段(沿用「默认关闭 + 显式 opt-in」守则):\n\n```go\n// SubscriptionProfile 新增字段(复用 Phase 4 Item 6 的 SubscriptionAutoRefresh 开关)\ntype SubscriptionIntegration struct {\n Kind string `json:\"kind\"` // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | ...\n BaseURL string `json:\"baseUrl\"` // new-api 站点地址(同时作为新建渠道的上游 baseURL)\n AccessToken string `json:\"-\"` // 系统访问令牌;加密存储,不出 API 响应\n UserID string `json:\"userId\"` // New-API-User 头所需\n AuthTokenMode string `json:\"authTokenMode,omitempty\"` // \"bearer\"(默认) | \"raw\"\n // 自动建 key 的模板\n ProvisionKeyName string `json:\"provisionKeyName,omitempty\"` // 默认 \"ccx-autopilot\"\n ProvisionGroup string `json:\"provisionGroup,omitempty\"` // 建 key 时指定分组,空=默认分组\n ProvisionModels []string `json:\"provisionModels,omitempty\"` // model_limits,空=不限制\n // 自动同步产物(回填,只读展示)\n ProvisionedTokenID int `json:\"provisionedTokenId,omitempty\"`\n LinkedChannelUID string `json:\"linkedChannelUid,omitempty\"`\n}\n```\n\n`AccessToken` 必须与现有敏感字段一样走加密存储,序列化到前端时脱敏(仅显示尾部若干位)。\n\n**new-api 接口清单**(本集成实际调用的子集,路径以主线 new-api 为准,字段名保持与上游一致):\n\n| 用途 | 方法 | 路径 | 请求体 | 响应关键字段 |\n|------|------|------|--------|--------------|\n| 校验令牌 + 查用户/余额 | GET | `/api/user/self` | — | `data.id`、`data.username`、`data.quota`(剩余额度)、`data.used_quota` |\n| 拉分组倍率 | GET | `/api/user/self/groups` | — | `data: { : { desc, ratio } }` |\n| 站点全部分组 | GET | `/api/group` | — | `data: string[]` |\n| 账号可用模型 | GET | `/api/user/models` | — | `data: string[]` |\n| 创建代理 key | POST | `/api/token/` | `CreateTokenRequest`(见下) | `data.key` / `success` |\n| key 列表 | GET | `/api/token/?p={p}&size={n}` | — | `data.items[]`(含 `id/key/name/status/remain_quota`) |\n| 更新 key | PUT | `/api/token/` | `{ ...token, id }` | `success` |\n| 删除 key | DELETE | `/api/token/{id}` | — | `success` |\n\n`CreateTokenRequest`:\n\n```json\n{\n \"name\": \"ccx-autopilot\",\n \"remain_quota\": 0,\n \"expired_time\": -1,\n \"unlimited_quota\": true,\n \"model_limits_enabled\": false,\n \"model_limits\": \"\",\n \"allow_ips\": \"\",\n \"group\": \"\"\n}\n```\n\n`quota` 是 new-api 内部额度单位(非美元),换算比例读 `GET /api/status` 的 `price` / `PaymentUSDRate`(无鉴权),或允许用户在订阅中手填换算率;MVP 直接以 quota 原值展示 + 站点换算率估算余额。\n\n**接入流程**(订阅中心「新增订阅 → 类型选 new-api」):\n\n```text\n1. 用户输入:站点 baseURL + 系统访问令牌(access_token) + userId\n (userId 可选自动获取:先用令牌调 /api/user/self 拿 data.id 回填)\n2. 校验:GET /api/user/self\n ├─ success=false / 401 → 令牌无效或 userId 不匹配,报错停止\n └─ 成功 → 记录 username、quota,创建 SubscriptionProfile(provider=new_api, originTier=second)\n3. 拉分组:GET /api/user/self/groups → 写入 GroupMultipliers({group: ratio})\n GET /api/user/models → 记录账号可用模型,供渠道 supportedModels 参考\n4. 建 key:POST /api/token/(按 ProvisionKeyName/Group/Models 模板)\n ├─ 已存在同名 ccx-autopilot key → 复用其 id,不重复创建(列表比对 name)\n └─ 新建成功 → 回填 ProvisionedTokenID,取 data.key 作为渠道 apiKey\n5. 自动建渠道:用 baseURL + 新建 key 创建一个上游渠道\n ├─ channelKind 由用户选择或按站点探测(messages/chat/responses/gemini)\n ├─ status=unknown,autoManaged=true,OriginType=relay\n ├─ 建立 SubscriptionUID → ChannelUID 链接(回填 LinkedChannelUID)\n └─ 触发 §8.4 的 Discovery + 能力测试标准流程\n6. 完成:订阅卡片显示余额、分组倍率、已绑定渠道、探测进度\n```\n\n关键点:new-api 集成**不额外造探测逻辑**,第 5 步之后完全复用现有 §8.4「添加并探测」链路;集成层只负责「令牌 → key + 分组 + 余额 + baseURL」的自动填充,把手填 baseURL/key 的动作自动化掉。\n\n**刷新与同步**(复用 `POST /api/subscriptions/{uid}/refresh`):\n\n| 触发 | 动作 |\n|------|------|\n| 手动刷新余额 | `GET /api/user/self` 更新 `Balance` + `BalanceUpdatedAt` |\n| 定时刷新(可选,opt-in) | 复用 Phase 4 Item 6 `SubscriptionAutoRefresh`;new-api 作为 `relay` provider adapter 纳入,默认关闭 |\n| 分组倍率变更 | 刷新时对比 `/api/user/self/groups`,`GroupMultipliers` 变化 → 更新并提示「套餐倍率已变」 |\n| key 失效检测 | 渠道侧探测到该 key 全 401 → 订阅卡片提示「key 可能已删除/禁用」,提供「重新建 key」一键动作(重跑第 4-5 步,复用旧渠道,只换 apiKey) |\n| 可用模型变更 | `/api/user/models` 结果哈希变化 → 触发对应渠道重新 Discovery |\n\n**安全与边界**:\n- `access_token` 权限等同 new-api 账户,须加密存储、脱敏展示、禁止写入日志;刷新失败连续 N 次自动停用该订阅的自动刷新,只保留手动。\n- 自动建 key 默认 `unlimited_quota=true` 但可由用户改为限额模板;建 key 是有副作用的写操作,须在接入前明确告知用户「将在 new-api 侧创建一个名为 ccx-autopilot 的令牌」。\n- new-api 是 `OriginTier=second`,**不得**用作隐私敏感的 routing advisor / classifier(遵守 §3.2.1 约束)。\n- fork 兼容:请求同时带 `New-API-User` 与 `User-id` 头;响应信封字段名(`success/data/message`)在部分 fork 可能不同,adapter 需容错解析,失败时回退手动录入。\n- 一个 new-api 订阅默认只自动建/绑定一个渠道;用户如需该站多协议渠道,可在同订阅下手动增建,共用同一 key。\n\n### 8.5.2 订阅 Provider Adapter 框架(多来源余额与接入)\n\n§8.5.1 的 new-api 集成不是特例,而是「订阅 Provider Adapter」框架的一个实现。Phase 4 Item 6 已落地 openai/anthropic/google 的余额刷新 adapter,本框架把它抽象成统一接口,让 new-api 及后续来源(GitHub Copilot、Codex、Nvidia、MiniMax、DeepSeek 等)按相同契约接入,避免每加一个来源就散落一套逻辑(DRY)。\n\n**统一接口**:\n\n```go\n// backend-go/internal/autopilot/subscription/adapter.go\ntype ProviderAdapter interface {\n Kind() string // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | \"copilot\" | \"deepseek\" | ...\n\n // 校验凭据,返回账号身份(用户名/账户标识);失败即凭据无效\n Verify(ctx context.Context, cred Credential) (AccountInfo, error)\n // 查余额/额度;不支持返回 ErrUnsupported,由 UI 降级为手动录入\n FetchBalance(ctx context.Context, cred Credential) (Balance, error)\n // 拉分组倍率;仅 new-api 类中转有,官方直连返回 ErrUnsupported\n FetchGroups(ctx context.Context, cred Credential) (map[string]GroupRatio, error)\n // 自动创建代理 key;官方直连通常不支持(key 即用户 API key),返回 ErrUnsupported\n ProvisionKey(ctx context.Context, cred Credential, tmpl KeyTemplate) (ProvisionedKey, error)\n // 可用模型列表;用于渠道 supportedModels 参考\n FetchModels(ctx context.Context, cred Credential) ([]string, error)\n}\n```\n\n每个能力独立、可选:adapter 只实现自己支持的部分,不支持的统一返回 `ErrUnsupported`(接口隔离原则),UI 据此隐藏/降级对应入口。所有 adapter 共用 `SubscriptionAutoRefresh` 的 opt-in 开关与 `Validate()` 兜底,与 Phase 4 现有防御模式一致。\n\n**能力矩阵**(✅ 支持 / ⚠️ 部分或需确认 / ❌ 不支持,走手动):\n\n| 来源 | Kind | 验证 | 查余额 | 拉分组 | 自动建 key | OriginTier |\n|------|------|:----:|:------:|:------:|:----------:|------------|\n| new-api family | `new_api` | ✅ | ✅ quota | ✅ | ✅ | second |\n| OpenAI 官方 | `openai` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Anthropic 官方 | `anthropic` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Google 官方 | `google` | ✅ | ⚠️ | ❌ | ❌ | first |\n| GitHub Copilot | `copilot` | ⚠️ token | ❌ 无额度概念 | ❌ | ❌ | first |\n| Codex plan | `codex` | ⚠️ | ⚠️ 用量窗口 | ❌ | ❌ | first |\n| Nvidia NIM | `nvidia` | ✅ | ⚠️ credits | ❌ | ❌ | first |\n| MiniMax | `minimax` | ✅ | ⚠️ | ❌ | ❌ | first |\n| DeepSeek | `deepseek` | ✅ | ✅ balance API | ❌ | ❌ | first |\n\n说明:官方直连来源普遍**不支持自动建 key**(用户的 API key 本身就是凭据,直接作为渠道 apiKey 录入即可)、也**没有分组倍率**(价格按官方定价表)。它们的 adapter 价值集中在「验证 + 查余额/用量窗口」,把 §3.2.4 UsageWindow 的官方用量 API 来源坐实。DeepSeek 有独立余额查询端点,可完整支持余额。Copilot 属订阅制、无 token 余额概念,只做验证 + 用量提示。\n\n**接入分层**(YAGNI:不强求一次做全,按价值排期):\n\n```text\nP0(本轮):new_api(完整:验证+余额+分组+建key+自动建渠道)\n —— 覆盖用户最主要的中转站场景\nP1:deepseek(验证+余额)、官方三家余额已在 Phase 4 完成 → 补 UsageWindow 对齐\nP2:nvidia / minimax(验证+credits/余额)\nP3:copilot / codex(验证+用量窗口提示,无余额)\n```\n\n每个来源的具体端点在实现时于 `adapter.go` 各 provider 文件内落地,本设计只固定接口契约与能力矩阵,避免文档与上游 API 变动强耦合。新增来源 = 实现 `ProviderAdapter` + 在能力矩阵登记,不改框架。\n\n**能力缺失与降级**:\n- adapter 任一能力返回 `ErrUnsupported` → 订阅中心对应字段回退为手动录入(余额手填、倍率手填),不报错、不阻塞接入。\n- adapter 验证失败/超时 → 订阅可仍以「手动模式」创建,仅失去自动刷新与自动建 key,其余流程(手填 baseURL+key 建渠道)不受影响。\n- 官方来源无「自动建 key」时,接入流程退化为 §8.4 标准快速添加:用户填 API key,adapter 只补验证与余额。\n- 所有自动刷新遵守 `SubscriptionAutoRefresh` 默认关闭;连续失败自动停用自动刷新并在驾驶舱「人工待办」提示。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3,\n \"providerTimePricing\": {\n \"deepseek\": {\n \"effectiveFrom\": \"2026-07-20T00:00:00+08:00\",\n \"timeZone\": \"Asia/Shanghai\",\n \"peakMultiplier\": 2,\n \"peakWindows\": [\n { \"start\": \"09:00\", \"end\": \"12:00\" },\n { \"start\": \"14:00\", \"end\": \"18:00\" }\n ]\n }\n }\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n`providerTimePricing.effectiveFrom` 使用 RFC3339;修改该值即可调整激活时间,设为空字符串则停用对应规则。\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**Phase 3A 状态(2026-07-09)**:低风险、不改真实路由语义的子集已完成并接入真实链路——`Manager.collectAll()` 新旧画像对比产出变更事件(`DetectProfileChanges`)、`ProfileChangelogStore`(环形内存 + SQLite,30 天机会性清理)、`EventHub` 内存 pub/sub、`GET /api/health-center/changelog`(历史)+ `GET /api/health-center/events`(WebSocket 实时推送)、`AutoDiscoveryRunner` 发布 `discovery_completed`/`auto_mapping_applied`、前端 `ProfileChangelogTimeline.vue` 接入健康中心页并支持断线自动重连。均为只读展示,未修改 `SmartRouter`/`EndpointAttemptPolicy` 调度逻辑。落地时顺带发现并修复一个真实缺口:浏览器原生 WebSocket 无法设自定义 header,为此给 `middleware.getAPIKey` 增加了 `Sec-WebSocket-Protocol` 鉴权回退(业界标准做法,仅新增一个 key 来源,不降低现有校验)。\n\n**Phase 3B(已全部完成,2026-07-09)**:模型自动映射、自动恢复探测、晋升/降级机制会直接改变真实调度决策,风险明显高于 3A,四个子项已分批全部完成。§12.2 P1.5 的 SQLite schema version / 版本化 migration / `OriginType`&`OriginTier` 旧配置 backfill / `killSwitch`+按 task class/channel disable **已于 2026-07-09 补强完成**(详见 §12.2 P1.5)。**Phase 3B-1(自动恢复探测补全)已于 2026-07-09 完成**:修复了 L2 探测子系统里此前从未打通的两个真实 bug——① `collectAll()` 每轮把新构造的画像整行 Upsert,会把 `ProbeWorker` 刚写入的 `LastProbeAt`/`ProbeSuccess`/`ConsecutiveProbeSuccess` 等字段清零,导致 6 小时探测冷却期形同虚设(修复:`carryForwardProbeFields` 在 Upsert 前搬运 Probe* 字段,不影响 L1 诊断字段的现有覆盖逻辑);② `extractRawAPIKey` 是返回 `KeyMask` 掩码值的占位 stub,探测请求实际带的是假 key(修复:新增 `APIKeyResolver` 回调 + `Manager.ResolveAPIKey`,通过遍历配置里对应渠道的 `APIKeys` 逐个计算 `KeyHashFromAPIKey` 比对来还原明文;resolver 未注入或未命中时 fail-open 跳过探测且不消耗预算)。同时补齐了 `degraded/limited → healthy` 的连续成功恢复路径(新增 `KeyEndpointProfile.ConsecutiveProbeSuccess` 计数器 + `HealthCheckConfig.ProbeRecoveryThreshold`,默认连续 2 次探测成功才恢复,避免单次探测噪声导致状态抖动;探测失败计数器清零)。**Phase 3B-3(StabilityTier 晋降级滞后)已于 2026-07-09 完成**(提交 664d3fff):在原本每轮从瞬时 1h 窗口重算的无记忆 `StabilityTier` 上加一层连续窗口滞后——新增 `KeyEndpointProfile.EffectiveStabilityTier`/`StabilityPendingTier`/`StabilityPendingStreak` 三字段 + 纯函数 `applyStabilityHysteresis`(对称阈值:连续 N 轮同值才切换生效档,`HealthCheckConfig.StabilityHysteresisWindows` 默认 2;首次画像直接采用,不等待阈值,避免新渠道卡在零值),`AggregateChannelProfile` 改读 `EffectiveStabilityTier`(空则回退 `StabilityTier`)。晋升/降级机制的数据模型仅限于 StabilityTier,不改变 QualityTier/SpeedTier/CostTier 语义。**Phase 3B-2(模型自动映射闭环)已于 2026-07-09 完成**(提交 0f02e988,随后修复一处 critical 集成 bug,提交 856372c4):新建 `ModelProfileStore`(SQLite,`model_profiles` 表)+ 扩展 `AutoDiscoveryRunner` 为每个发现模型写入 `ModelProfile`(仅 `AutoManaged` 渠道);新建 `ModelResolver` 实现 §5.4 五步逻辑(手动 `RedirectModel` 短路 → `GetModelProfiles` → `filterByCapabilityFloor` → `rankBySimilarity`);`scheduler` 包新增 `ModelSupportResolverFunc` hook(fail-open,未注入或返回不支持时严格回退到原 `ExplainModelSupport`);`endpoint_policy.go` 的 `resolveMappedModel`(此前是永不触发的死代码——`profile.ModelMapping` 从未被写入)现调用 `ModelResolver`;`handlers/common` 在 `RedirectModel` 之后应用 `MappedModel` 覆盖请求体,并按 `EchoMappedModel` 配置回显 `X-CCX-Mapped-Model`/`X-CCX-Original-Model`/`X-CCX-Mapping-Source` 响应头。三条件安全门控:`ModelMapping.AutoResolve==true`(**默认由死配置的 true 改为 false**)AND `RoutingMode ∈ {assist, auto}` AND 无 `KillSwitch`,三者任一不满足则所有新增代码路径行为与今天字节级一致;且仅 `AutoManaged` 渠道触发,手动配置渠道完全不受影响。对抗性复核发现并修复一个真实 critical bug:`scoreEndpointForKey` 用空 `channelUID` 计算 `EndpointUID` 并写入 `modelByUID`,而 `handlers` 层用真实 `channelUID` 查 `modelByUID`,两者哈希永不相等导致 `MappedModel` 在 key 级调度路径上完全查不到(提交 856372c4 修复:命中画像后改用 `profile.EndpointUID`,新增回归测试锁定该不变量)。至此 Phase 3B 四个子项(自动恢复探测、StabilityTier 滞后、模型自动映射、active filter 前置判定)全部完成;SLO regression 自动回滚仍是独立子系统,留待后续单独立项。\n\n**范围**:\n- [x] 运行时指标驱动画像实时更新(Phase 3A:`collectAll()` 每轮 diff + changelog + WebSocket 推送)\n- [x] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)—— Phase 3B-2(2026-07-09 完成,提交 0f02e988 + 856372c4)\n- [x] active model filter / context filter 支持自动映射前置判定 —— Phase 3B-2:`scheduler.ModelSupportResolverFunc` fail-open 接入 `select.go`\n- [x] 自动恢复探测(limited/dead → healthy)—— Phase 3B-1(2026-07-09 完成):`APIKeyResolver` + `carryForwardProbeFields` + `ConsecutiveProbeSuccess`/`ProbeRecoveryThreshold`\n- [x] 晋升/降级机制(连续成功→升级,连续失败→降级)—— Phase 3B-3(2026-07-09 完成,提交 664d3fff):`StabilityTier` 连续窗口滞后(`EffectiveStabilityTier`),暂不含 QualityTier/SpeedTier/CostTier\n- [x] WebSocket 推送画像变更事件(Phase 3A:`GET /api/health-center/events`)\n- [x] 前端画像变更历史/时间线(Phase 3A:`ProfileChangelogTimeline.vue`)\n\n**预估工期**:2-3 周(3A、3B-1、3B-2、3B-3 均已完成;advisor 与全局 auto 的 SLO 回滚均已补齐)\n\n> 状态校正(2026-07-17):上方 Phase 3B 历史说明中的“SLO regression 自动回滚待单独立项”已完成;advisor 回滚与全局 auto 上线闸门/自动降级是两个独立保护层。\n\n### Phase 4:高级特性\n\n**Phase 4 状态(2026-07-09,全部完成)**:8 项全部实现并接入真实链路,均遵循\"默认关闭 + 显式 opt-in\"安全守则。\n\n**范围**:\n- [x] 多维度标签系统扩展(用户自定义标签)——Item 1\n- [x] 成本报表:按用户/模型/渠道/key 统计真实有效成本——Item 2(新增 `proxyKeyMask` 身份贯穿链路:`middleware.GetProxyKeyMask` → `ChannelLog`/`RequestRecord` → SQLite `proxy_key_mask` 列,供 Item 4 复用)\n- [x] Trusted advisor SLO regression 自动回滚——Item 3(默认关闭;`SLORollbackConfig`,连续 degrading 窗口数默认 3,不做自动恢复以避免震荡)\n- [x] 渠道推荐:根据使用模式推荐新渠道——Item 4\n- [x] 批量渠道管理(导入/导出/模板)——Item 5\n- [x] 订阅中心 provider adapter:可选自动刷新余额/套餐状态——Item 6(默认关闭;仅 openai/anthropic/google 且显式填写 BillingAPIKey 的订阅生效,中转/公益渠道继续手动维护)\n- [x] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板——Item 7\n- [x] A/B 测试:低比例统计抽样双发——Item 8(默认关闭,默认 1% 抽样 + 每小时 60 次硬预算;主请求路径不受影响,影子请求在主响应返回后异步发起)\n\n**关键实现说明**:\n- Item 3/6/8 均在 `AutopilotRoutingConfig` 新增独立配置块(`SLORollback`/`SubscriptionAutoRefresh`/`ABTest`),`Validate()` 统一做负值/越界兜底,保持与既有字段一致的防御模式。\n- Item 4 直接复用 Item 2 的 `proxyKeyMask` 身份标记,未重新发明身份识别机制。\n- **全局 auto 安全闸门(2026-07-17)**:请求终态按 15 分钟窗口无偏聚合,详细 trace 继续抽样;管理 API 只在 shadow/assist 已积累至少 500 个有效请求、24 小时连续观测且成功率/fallback/fail-open/p95 均达标时允许切换 `auto`。后台连续检测三个已完成 auto 窗口,相对最近七天安全模式基线持续恶化时,持久化降级到 `assist` 并记录 `autopilot_auto_safety_events`,不自动恢复。\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- ~~SQLite schema version 和幂等 migration。~~ **已完成**:`ensureSchemaVersion` 基于 `PRAGMA user_version`,当前 7 张表定版为 v1 基线;库版本高于代码版本时返回 error(fail-closed,不做任何写操作),交给下游 fail-open 分支接管。\n- ~~旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。~~ **已完成**:`ensureOriginBackfill` 只补 `\"unknown\"`,不做任何基于 URL/名称的猜测推断;已接线到 `Profiler.DeriveEndpointProfile`,`BreakTieByOriginTier` 现在能吃到真实数据(此前一直是 fallback 的 `unknown`)。\n- ~~profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。~~ **已确认满足并测试锁定**:`main.go` 现有结构本就是 fail-open(`NewProfileStore`/`NewManager` 出错只打日志、`autopilotManager` 保持 `nil`,下游全部有 `nil` 守卫),本次新增 `schema_migration_test.go` 用测试锁定这条不变量。\n- ~~全局 `killSwitch`、按 task class disable、按 channel disable。~~ **已完成**:新增 `AutopilotRoutingConfig.DisabledTaskClasses`/`DisabledChannelUIDs`;`CandidateFilterFor`/`executeFilter`/`BuildPlan` 三处一致接入,`smart_router_invariant_test.go` 覆盖\"task class 禁用后回退默认调度\"\"channel 禁用后不出现在候选里\"\"dry-run 与真实路径行为一致\"三条不变量。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。**待实现**:`TrustedRoutingAdvisor.promotionMode` 目前是 `manual`,shadow→candidate→active 已有人工审查关卡;自动回滚是独立的新子系统(需要基线捕获窗口 + 实时对比 + 阈值判定),留待下一轮单独设计落地。\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | shadow 样本/准确率/critical misroute 门槛;active 后 SLO regression 自动 rollback;全局 kill switch |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "87401f2cb72f7da7603c1ecacc2acf95d3b98d5c": {"t": 1785759349.9827044, "n": 158094, "h": "3c098b19e4d087b1", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.2.3 同订阅多 Key:能力共享与配额负载均衡\n\n**问题**:官方订阅 plan(Claude Max、Codex plan 等)允许用户持有多个 key,或购买多份相同套餐。若每个 endpoint 独立探测与配置,探测预算被浪费、配置重复;请求量大时还需要在同 plan 的多个 key 之间按官方 RPM 限速自动负载均衡,而不是打满第一个 key 才溢出到下一个。\n\n**能力检测去重(同一套餐只需一套能力检测和配置)**:\n\n```text\nSubscriptionUID 相同的 endpoint 共享一份「订阅级能力画像」:\n 模型列表 / vision / tool / reasoning 支持 / 协议兼容开关 → 只探测一次,\n 存在 SubscriptionProfile.SharedCapability,endpoint 画像以引用方式继承。\n\n仍然 per-endpoint 维护的:健康状态、延迟、熔断、用量窗口\n (key 可能被单独封禁/限速,能力相同不代表运行态相同)\n\n继承失效条件:某 key 实测模型列表哈希与订阅级不一致(GroupChangeDetector 信号)\n → 该 key 脱离继承、独立探测,并在 UI 标记「与套餐能力不一致」警告。\n```\n\n**配额负载均衡(Phase 1 shadow 展示,Phase 2 生效)**:\n\n```text\n每 key 维护配额余量视图:\n remainingRPM = officialRPM − 当前窗口已用\n officialRPM 来源优先级:内置 manifest > 用户配置 > RateLimitDiscoverer 建议\n\n同订阅多 key 时按「剩余余量加权」分配请求(余量大者优先、\n窗口最早重置者兜底),替代顺序打满:\n 仅当渠道 autoManaged 且同一 SubscriptionUID 下 key 数 > 1 时启用;\n 否则保持现有 sequential/random key 顺序策略不变。\n\n某 key 的窗口配额耗尽 → 临时降权并切到同订阅其他 key,\n不熔断整个渠道(这是配额耗尽,不是故障)。\n```\n\n### 3.2.4 订阅用量窗口 (Usage Windows)\n\n部分官方 plan 提供 5h / 日 / 周 / 月等窗口的用量查询与限制(如 Claude 订阅的 5h 滚动窗口 + 周配额)。在 key 的位置展示这些窗口,帮助用户掌握使用情况:\n\n```go\ntype UsageWindow struct {\n Window string `json:\"window\"` // \"5h\" | \"day\" | \"week\" | \"month\"\n Used float64 `json:\"used\"` // 已用量\n Limit float64 `json:\"limit\"` // 上限,0 = 未知\n Unit string `json:\"unit\"` // requests | tokens | credits | percent\n ResetsAt time.Time `json:\"resetsAt\"` // 窗口重置时间\n Source string `json:\"source\"` // official_api | response_header | local_metering\n FetchedAt time.Time `json:\"fetchedAt\"`\n}\n// KeyEndpointProfile 与 SubscriptionProfile 均增加 UsageWindows []UsageWindow\n```\n\n数据来源优先级:\n\n```text\n1. 官方用量 API:有 provider adapter 的 plan 主动拉取(§Phase 4 订阅 adapter 的可提前子集)\n2. 响应头:anthropic-ratelimit-* / x-ratelimit-* 等(复用 RateLimitDiscoverer 的信号 tap)\n3. 本地计量兜底:CCX 按 key 累计请求数/token(误差来自窗口起点对齐,标注 Source=local_metering)\n```\n\nUI 要求:key 行内显示主窗口(5h)用量进度条,悬浮展开全部窗口;用量 >80% 黄色预警、超限红色并显示预计重置时间。调度联动(Phase 2):窗口余量进入配额负载均衡权重,窗口耗尽的 key 临时降权而非熔断。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n#### 4.2.1 内置模型清单 (Builtin Models Manifest)\n\n**问题**:部分官方订阅 plan 的入口没有正确的 models 接口——要么不存在(404)、要么需要额外权限、要么返回与订阅实际可用模型不符的列表(典型:Claude 订阅 OAuth 入口、Codex plan 入口、部分官方企业网关)。对这些 baseURL 做模型发现会失败或产出错误画像。\n\n**方案**:CCX 内置「已知官方 baseURL → 模型清单」映射表,命中时直接返回内置内容,完全跳过上游 models 接口:\n\n```go\n// backend-go/internal/config/builtin_models_manifest.go(生成式,随模型注册表更新)\ntype BuiltinModelsManifest struct {\n BaseURLPattern string `json:\"baseUrlPattern\"` // 精确 host 或前缀匹配,如 \"api.anthropic.com\"\n ServiceType string `json:\"serviceType\"` // messages/responses/…\n PlanHint string `json:\"planHint,omitempty\"` // 订阅类型提示(如 \"claude_subscription\")\n ModelIDs []string `json:\"modelIds\"` // 该入口实际可用的模型清单\n DisableProbe bool `json:\"disableProbe\"` // true 时 Discovery 跳过 GET /v1/models\n}\n```\n\n**两个生效位置**:\n\n```text\n1. Discovery(§4.2 流程第 2 步之前):\n baseURL 命中 manifest 且 DisableProbe=true\n → 跳过 GET /v1/models,直接用 ModelIDs 生成 ModelProfile[]\n → ProfileSource = \"builtin_manifest\",不再因 models 接口失败而误判 misconfigured\n\n2. /v1/models 代理响应(现有 Models API):\n 客户端向 CCX 请求 /v1/models 时,聚合结果中该渠道的贡献\n 直接取内置清单,不向上游发起 models 请求\n (渠道显式配置了 supportedModels 时仍以用户配置优先)\n```\n\n**维护与覆盖顺序**:\n\n```text\n渠道显式 supportedModels(用户手动) > 内置 manifest > 上游 models 接口实探 > 内置候选模型回退\n```\n\nmanifest 与模型注册表同源生成(`generated_model_registry.go` 同级),新模型发布时随注册表一起更新;HealthAnalyzer 对命中 manifest 的 endpoint 不将「models 接口 404/403」计入 misconfigured 信号。\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20;assist 下 dead/misconfigured 额外 -100 并保留在队尾\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法细分时进入 `general`;有规范 Knowledge 基准则使用其软评分,缺少直接证据时仍取 0.5 中性值。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n**手动 L3 第一版状态(2026-07-15)**:已实现 `pq-v1-20260715` 固定 JSON canary,覆盖\nMessages、Chat Completions、Responses 与 Gemini 上游协议。调用方只能指定 `endpointUid`、\n`modelId` 和 1~3 次重复数,不能注入 prompt。评分仍按 40% 完整性、30% 语义正确性、\n15% 严格 JSON 格式、15% 延迟执行;第一版使用非流式完整响应延迟作为首 token 延迟代理,\n后续引入流式采样时再替换该维度。单次有效样本 confidence=0.6,最多三次提升到 0.8;\n失败样本按 0 分计入均值,全部失败不写画像。\n\n安全边界:仅管理 API 手动触发,不启动自动 worker;当前进程按 UTC 日设置 12 次硬预算,\n请求前原子预留,单次 90 秒超时、响应体上限 64 KiB。同一 endpoint×model 不并发执行;\n单次请求最多允许 2048 个输出 token,以容纳推理模型的隐藏思考并限制最坏额度消耗。\n对声明可控推理的模型固定采用最低可用 effort;Claude 兼容端点若只暴露\n`reasoning_content` 而未声明 thinking 模式,则与 capability-test 一致显式关闭 thinking。\n原始模型输出和明文 Key 既不落盘也不返回,只保存分数、canary 版本、置信度和脱敏判定。\n`user_feedback` 仍高于 `probe`,自动发现重建 ModelProfile 时必须保留既有质量证据。\n跨进程共享预算与 LLM-as-judge/语义向量评分仍属于后续阶段。\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设 + 自定义\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n| `custom` | 用户自定义 | 用户自定义 | 高级用户微调,范围 0.0 ~ 3.0 |\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法细分的通用任务;缺少基准证据时中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general;有规范 Knowledge 基准时使用其软评分,缺少直接证据时取 0.5 中性。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? canonicalBenchmark[model][domain] * providerQualityFactor\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 规范模型基准 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n#### 5.7.4 规范模型能力基准\n\n`shared/model-registry/ccx_model_registry.json` 的 `benchmarkProfiles` 独立保存规范模型能力上界,不与 `upstreamCapabilities` 混合。后者仍是 context、vision、tool calls 等硬能力的事实源;benchmark 只参与 Autopilot 软评分和 trace 解释,绝不改变 `supportedModels` 或 CapabilityFloor。\n\n```text\nEffectiveCapability[domain]\n = CanonicalCeiling[model, domain]\n × ProviderQualityFactor[endpoint]\n\nProviderQualityFactor = 1 - confidence × (1 - providerQualityScore)\n无渠道质量证据或 confidence<0.5 时 factor=1;可信证据只能从规范模型上界向下折算。\n```\n\n基准数据必须保留领域向量、来源、核验日期、lane、共享结果数和可比类别覆盖率,不能只保存站点 Overall。当前 BenchLM 接入为 `provisional`:Claude Opus 4.8、GPT-5.6 Terra、GPT-5.6 Sol 各有 30 个共享结果,8 类中 5 类可比,核验日期为 2026-07-13。\n\n领域映射:Knowledge → `general`,Math → `reasoning`,Coding → `coding` / `code_review`,Agentic → `agentic`,Multimodal → `aesthetics_ui`(仅弱代理,解释置信度减半)。Writing / Translation 没有直接类别证据时继续回退家族种子或 0.5。BenchLM 的 cohort 归一化、缺失项插补和外部校准决定了 Overall 不是绝对能力值,因此路由只消费可映射的原始类别分。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**请求意图边界(ModelRoutingPolicy)**\n\n跨模型替代不是所有模型的默认能力,只对白名单客户端入口开放:\n\n```text\nmessages:\n fable / opus / sonnet / haiku 及对应 claude-* 完整模型名 → adaptive\nresponses:\n gpt-5.6* / gpt-5.5 / gpt-5.4* / codex-auto-review → adaptive\n其他协议或模型:\n exact-only,只能在探测到相同规范化模型 ID 的 endpoint 之间调度\n```\n\n`adaptive` 允许在满足 Capability Floor 后选择 GLM、MiMo 等其他模型族;`exact-only` 禁止跨模型替代。用户显式配置的 `modelMapping` 仍优先于该策略。调度预览和 endpoint 实际解析必须调用同一分类函数,避免诊断结果与真实请求分叉。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选优\n // 上下文窗口只负责准入,不对更大的窗口扣分。\n // 排序优先级:\n // a. 模型质量档越高越优先\n // b. 带置信度折算的供应商实测质量\n // c. 探测延迟\n // d. 公开模型成本\n // e. 同模型族与 model ID 仅作为确定性兜底\n best := rankEligibleModels(eligible, requestModel)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - autoManaged 的空 supportedModels 不表示支持全部,必须由 endpoint ModelProfile 裁决\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n当 ModelResolver 已正常初始化且路由处于 `assist/auto + autoResolve=true` 时,autoManaged 渠道的画像拒绝是权威结果,调度器不得再 fail-open 到空 `supportedModels` 的兼容语义。Resolver 不可用、开关关闭或 `off/shadow` 时仍保留原有 fail-open,保证急停和降级路径不依赖画像库。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\nGET /api/health-center/provider-quality/budget → 查看本进程手动 L3 每日预算\nPOST /api/health-center/provider-quality/probe → 对指定 endpoint×model 执行固定质量 canary\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n#### 8.0.1 实现现状与演进方向(2026-07-09 核实)\n\n四界面在 **Web 前端(`frontend/src`)已大面积落地**,不是纯文档:\n\n| 界面 | Web 实现 | Desktop 实现 |\n|------|----------|--------------|\n| 健康中心 | ✅ `HealthCenterView.vue` + `HealthCenterStats`/`HealthChannelTable`/`HealthChannelDetail`/`ProfileChangelogTimeline`,路由 `/health` | ❌ 无 |\n| 订阅中心 | ✅ `SubscriptionsView.vue` + `SubscriptionPlanTable`,CRUD + 刷新余额,路由 `/subscriptions` | ⚠️ 独立旧实现 `subscriptions/SubscriptionTab.vue`(与 Web 两套代码) |\n| 渠道中心 | ✅ `ChannelOrchestration.vue`(六态 `ChannelHealthBadge`、来源 chips、`autoManaged` 图标齐备) | ❌ 未同步 |\n| 管理面板 | ⚠️ `AutopilotModePanel.vue` 仅暴露 mode/killSwitch/costPreference | ❌ 无 |\n| 驾驶舱 | ✅ `CockpitView.vue`(观测型总览:健康统计+余额汇总+渠道推荐) | ⚠️ `cockpit` tab 渲染的是会话 Dashboard,职责不同 |\n\n**与本设计意图的关系**(对齐用户「自动调度为主、废旧手动驾驶舱」的诉求):\n- **旧的「手动调度驾驶舱」在 Web 端已不存在**。`CockpitView` 已是 §8.7 定义的观测型总览,只回答健康/省钱/风险/待办四问题,不做渠道配置编辑,符合「程序自动选、人只观测+有限干预」的方向。无需再废除,只需继续收敛为纯观测。\n- **统一提交入口已存在**(§8.4 快速添加 + §8.5 订阅中心),new-api 集成(§8.5.1)在此之上把「填 baseURL+key」进一步自动化。\n\n**前端演进待办**(本设计新增/明确):\n1. **补 `ManualRoutingIntent` 前端**:这是用户要的「有限偏向性干预」,后端已有、两个前端均未实现(§8.3 试用意图入口尚缺)。\n2. **Desktop 前端对齐 Web**:健康中心/渠道自动托管/管理面板/观测型驾驶舱在 Desktop 几乎空白,需同步或复用 Web 组件,避免长期维护两套。\n3. **管理面板补字段**:暴露 `l2ProbeEnabled`、探测预算、new-api/多来源订阅 adapter 开关(§8.5.2)等,当前只有 3 个字段。\n4. **订阅中心接 §8.5.1/§8.5.2**:新增「new-api」订阅类型与 Provider Adapter 能力矩阵驱动的动态表单。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.5.1 new-api 订阅集成(统一账号接入)\n\n**目标**:用户在订阅中心手填一个 new-api(及其 fork)账号的「系统访问令牌」即可,CCX 自动用该令牌创建代理专用 key、拉取分组倍率、查询额度余额,并把「站点 baseURL + 新建 key」自动落成一个上游渠道,交给 Autopilot 探测与调度。\n\n**范围约束**:\n- 只使用 new-api 用户在「个人设置」生成的 **系统访问令牌(access_token)** 一种凭据;令牌用于 API 调用的身份验证,属敏感数据,须妥善加密存储。\n- **不做** cookie 登录 + 2FA + 导出后台渠道明文 key 的管理员迁移路径(all-api-hub 的 newApiSession 能力),本设计不涉及。\n- new-api 归入 `OriginType=relay`、`OriginTier=second`(中转站),信任等级不因自动化提升;实时质量仍由运行画像决定。\n- 认证令牌像其他订阅凭据一样由用户手动录入,CCX 不代替用户在 new-api 侧注册或登录。\n\n**认证与凭据模型**:\n\nnew-api family 的管理接口鉴权(依据 all-api-hub 传输层实现):\n\n```text\nAuthorization: Bearer # 系统访问令牌;raw 模式可去掉 Bearer 前缀\nNew-API-User: # 当前用户 ID(必带)\nUser-id: # fork 兼容回退(Veloera/voapi/Super-API 等)\nContent-Type: application/json\n```\n\n统一响应信封:`{ \"success\": bool, \"data\": T, \"message\": string }`;解析时先判 `success`,再取 `data`。\n\n**SubscriptionProfile 扩展**:\n\n在 §3.2.2 的 `SubscriptionProfile` 基础上,为可自动接入的订阅新增 provider 集成字段(沿用「默认关闭 + 显式 opt-in」守则):\n\n```go\n// SubscriptionProfile 新增字段(复用 Phase 4 Item 6 的 SubscriptionAutoRefresh 开关)\ntype SubscriptionIntegration struct {\n Kind string `json:\"kind\"` // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | ...\n BaseURL string `json:\"baseUrl\"` // new-api 站点地址(同时作为新建渠道的上游 baseURL)\n AccessToken string `json:\"-\"` // 系统访问令牌;加密存储,不出 API 响应\n UserID string `json:\"userId\"` // New-API-User 头所需\n AuthTokenMode string `json:\"authTokenMode,omitempty\"` // \"bearer\"(默认) | \"raw\"\n // 自动建 key 的模板\n ProvisionKeyName string `json:\"provisionKeyName,omitempty\"` // 默认 \"ccx-autopilot\"\n ProvisionGroup string `json:\"provisionGroup,omitempty\"` // 建 key 时指定分组,空=默认分组\n ProvisionModels []string `json:\"provisionModels,omitempty\"` // model_limits,空=不限制\n // 自动同步产物(回填,只读展示)\n ProvisionedTokenID int `json:\"provisionedTokenId,omitempty\"`\n LinkedChannelUID string `json:\"linkedChannelUid,omitempty\"`\n}\n```\n\n`AccessToken` 必须与现有敏感字段一样走加密存储,序列化到前端时脱敏(仅显示尾部若干位)。\n\n**new-api 接口清单**(本集成实际调用的子集,路径以主线 new-api 为准,字段名保持与上游一致):\n\n| 用途 | 方法 | 路径 | 请求体 | 响应关键字段 |\n|------|------|------|--------|--------------|\n| 校验令牌 + 查用户/余额 | GET | `/api/user/self` | — | `data.id`、`data.username`、`data.quota`(剩余额度)、`data.used_quota` |\n| 拉分组倍率 | GET | `/api/user/self/groups` | — | `data: { : { desc, ratio } }` |\n| 站点全部分组 | GET | `/api/group` | — | `data: string[]` |\n| 账号可用模型 | GET | `/api/user/models` | — | `data: string[]` |\n| 创建代理 key | POST | `/api/token/` | `CreateTokenRequest`(见下) | `data.key` / `success` |\n| key 列表 | GET | `/api/token/?p={p}&size={n}` | — | `data.items[]`(含 `id/key/name/status/remain_quota`) |\n| 更新 key | PUT | `/api/token/` | `{ ...token, id }` | `success` |\n| 删除 key | DELETE | `/api/token/{id}` | — | `success` |\n\n`CreateTokenRequest`:\n\n```json\n{\n \"name\": \"ccx-autopilot\",\n \"remain_quota\": 0,\n \"expired_time\": -1,\n \"unlimited_quota\": true,\n \"model_limits_enabled\": false,\n \"model_limits\": \"\",\n \"allow_ips\": \"\",\n \"group\": \"\"\n}\n```\n\n`quota` 是 new-api 内部额度单位(非美元),换算比例读 `GET /api/status` 的 `price` / `PaymentUSDRate`(无鉴权),或允许用户在订阅中手填换算率;MVP 直接以 quota 原值展示 + 站点换算率估算余额。\n\n**接入流程**(订阅中心「新增订阅 → 类型选 new-api」):\n\n```text\n1. 用户输入:站点 baseURL + 系统访问令牌(access_token) + userId\n (userId 可选自动获取:先用令牌调 /api/user/self 拿 data.id 回填)\n2. 校验:GET /api/user/self\n ├─ success=false / 401 → 令牌无效或 userId 不匹配,报错停止\n └─ 成功 → 记录 username、quota,创建 SubscriptionProfile(provider=new_api, originTier=second)\n3. 拉分组:GET /api/user/self/groups → 写入 GroupMultipliers({group: ratio})\n GET /api/user/models → 记录账号可用模型,供渠道 supportedModels 参考\n4. 建 key:POST /api/token/(按 ProvisionKeyName/Group/Models 模板)\n ├─ 已存在同名 ccx-autopilot key → 复用其 id,不重复创建(列表比对 name)\n └─ 新建成功 → 回填 ProvisionedTokenID,取 data.key 作为渠道 apiKey\n5. 自动建渠道:用 baseURL + 新建 key 创建一个上游渠道\n ├─ channelKind 由用户选择或按站点探测(messages/chat/responses/gemini)\n ├─ status=unknown,autoManaged=true,OriginType=relay\n ├─ 建立 SubscriptionUID → ChannelUID 链接(回填 LinkedChannelUID)\n └─ 触发 §8.4 的 Discovery + 能力测试标准流程\n6. 完成:订阅卡片显示余额、分组倍率、已绑定渠道、探测进度\n```\n\n关键点:new-api 集成**不额外造探测逻辑**,第 5 步之后完全复用现有 §8.4「添加并探测」链路;集成层只负责「令牌 → key + 分组 + 余额 + baseURL」的自动填充,把手填 baseURL/key 的动作自动化掉。\n\n**刷新与同步**(复用 `POST /api/subscriptions/{uid}/refresh`):\n\n| 触发 | 动作 |\n|------|------|\n| 手动刷新余额 | `GET /api/user/self` 更新 `Balance` + `BalanceUpdatedAt` |\n| 定时刷新(可选,opt-in) | 复用 Phase 4 Item 6 `SubscriptionAutoRefresh`;new-api 作为 `relay` provider adapter 纳入,默认关闭 |\n| 分组倍率变更 | 刷新时对比 `/api/user/self/groups`,`GroupMultipliers` 变化 → 更新并提示「套餐倍率已变」 |\n| key 失效检测 | 渠道侧探测到该 key 全 401 → 订阅卡片提示「key 可能已删除/禁用」,提供「重新建 key」一键动作(重跑第 4-5 步,复用旧渠道,只换 apiKey) |\n| 可用模型变更 | `/api/user/models` 结果哈希变化 → 触发对应渠道重新 Discovery |\n\n**安全与边界**:\n- `access_token` 权限等同 new-api 账户,须加密存储、脱敏展示、禁止写入日志;刷新失败连续 N 次自动停用该订阅的自动刷新,只保留手动。\n- 自动建 key 默认 `unlimited_quota=true` 但可由用户改为限额模板;建 key 是有副作用的写操作,须在接入前明确告知用户「将在 new-api 侧创建一个名为 ccx-autopilot 的令牌」。\n- new-api 是 `OriginTier=second`,**不得**用作隐私敏感的 routing advisor / classifier(遵守 §3.2.1 约束)。\n- fork 兼容:请求同时带 `New-API-User` 与 `User-id` 头;响应信封字段名(`success/data/message`)在部分 fork 可能不同,adapter 需容错解析,失败时回退手动录入。\n- 一个 new-api 订阅默认只自动建/绑定一个渠道;用户如需该站多协议渠道,可在同订阅下手动增建,共用同一 key。\n\n### 8.5.2 订阅 Provider Adapter 框架(多来源余额与接入)\n\n§8.5.1 的 new-api 集成不是特例,而是「订阅 Provider Adapter」框架的一个实现。Phase 4 Item 6 已落地 openai/anthropic/google 的余额刷新 adapter,本框架把它抽象成统一接口,让 new-api 及后续来源(GitHub Copilot、Codex、Nvidia、MiniMax、DeepSeek 等)按相同契约接入,避免每加一个来源就散落一套逻辑(DRY)。\n\n**统一接口**:\n\n```go\n// backend-go/internal/autopilot/subscription/adapter.go\ntype ProviderAdapter interface {\n Kind() string // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | \"copilot\" | \"deepseek\" | ...\n\n // 校验凭据,返回账号身份(用户名/账户标识);失败即凭据无效\n Verify(ctx context.Context, cred Credential) (AccountInfo, error)\n // 查余额/额度;不支持返回 ErrUnsupported,由 UI 降级为手动录入\n FetchBalance(ctx context.Context, cred Credential) (Balance, error)\n // 拉分组倍率;仅 new-api 类中转有,官方直连返回 ErrUnsupported\n FetchGroups(ctx context.Context, cred Credential) (map[string]GroupRatio, error)\n // 自动创建代理 key;官方直连通常不支持(key 即用户 API key),返回 ErrUnsupported\n ProvisionKey(ctx context.Context, cred Credential, tmpl KeyTemplate) (ProvisionedKey, error)\n // 可用模型列表;用于渠道 supportedModels 参考\n FetchModels(ctx context.Context, cred Credential) ([]string, error)\n}\n```\n\n每个能力独立、可选:adapter 只实现自己支持的部分,不支持的统一返回 `ErrUnsupported`(接口隔离原则),UI 据此隐藏/降级对应入口。所有 adapter 共用 `SubscriptionAutoRefresh` 的 opt-in 开关与 `Validate()` 兜底,与 Phase 4 现有防御模式一致。\n\n**能力矩阵**(✅ 支持 / ⚠️ 部分或需确认 / ❌ 不支持,走手动):\n\n| 来源 | Kind | 验证 | 查余额 | 拉分组 | 自动建 key | OriginTier |\n|------|------|:----:|:------:|:------:|:----------:|------------|\n| new-api family | `new_api` | ✅ | ✅ quota | ✅ | ✅ | second |\n| OpenAI 官方 | `openai` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Anthropic 官方 | `anthropic` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Google 官方 | `google` | ✅ | ⚠️ | ❌ | ❌ | first |\n| GitHub Copilot | `copilot` | ⚠️ token | ❌ 无额度概念 | ❌ | ❌ | first |\n| Codex plan | `codex` | ⚠️ | ⚠️ 用量窗口 | ❌ | ❌ | first |\n| Nvidia NIM | `nvidia` | ✅ | ⚠️ credits | ❌ | ❌ | first |\n| MiniMax | `minimax` | ✅ | ⚠️ | ❌ | ❌ | first |\n| DeepSeek | `deepseek` | ✅ | ✅ balance API | ❌ | ❌ | first |\n\n说明:官方直连来源普遍**不支持自动建 key**(用户的 API key 本身就是凭据,直接作为渠道 apiKey 录入即可)、也**没有分组倍率**(价格按官方定价表)。它们的 adapter 价值集中在「验证 + 查余额/用量窗口」,把 §3.2.4 UsageWindow 的官方用量 API 来源坐实。DeepSeek 有独立余额查询端点,可完整支持余额。Copilot 属订阅制、无 token 余额概念,只做验证 + 用量提示。\n\n**接入分层**(YAGNI:不强求一次做全,按价值排期):\n\n```text\nP0(本轮):new_api(完整:验证+余额+分组+建key+自动建渠道)\n —— 覆盖用户最主要的中转站场景\nP1:deepseek(验证+余额)、官方三家余额已在 Phase 4 完成 → 补 UsageWindow 对齐\nP2:nvidia / minimax(验证+credits/余额)\nP3:copilot / codex(验证+用量窗口提示,无余额)\n```\n\n每个来源的具体端点在实现时于 `adapter.go` 各 provider 文件内落地,本设计只固定接口契约与能力矩阵,避免文档与上游 API 变动强耦合。新增来源 = 实现 `ProviderAdapter` + 在能力矩阵登记,不改框架。\n\n**能力缺失与降级**:\n- adapter 任一能力返回 `ErrUnsupported` → 订阅中心对应字段回退为手动录入(余额手填、倍率手填),不报错、不阻塞接入。\n- adapter 验证失败/超时 → 订阅可仍以「手动模式」创建,仅失去自动刷新与自动建 key,其余流程(手填 baseURL+key 建渠道)不受影响。\n- 官方来源无「自动建 key」时,接入流程退化为 §8.4 标准快速添加:用户填 API key,adapter 只补验证与余额。\n- 所有自动刷新遵守 `SubscriptionAutoRefresh` 默认关闭;连续失败自动停用自动刷新并在驾驶舱「人工待办」提示。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3,\n \"providerTimePricing\": {\n \"deepseek\": {\n \"effectiveFrom\": \"2026-07-20T00:00:00+08:00\",\n \"timeZone\": \"Asia/Shanghai\",\n \"peakMultiplier\": 2,\n \"peakWindows\": [\n { \"start\": \"09:00\", \"end\": \"12:00\" },\n { \"start\": \"14:00\", \"end\": \"18:00\" }\n ]\n }\n }\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n`providerTimePricing.effectiveFrom` 使用 RFC3339;修改该值即可调整激活时间,设为空字符串则停用对应规则。\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**Phase 3A 状态(2026-07-09)**:低风险、不改真实路由语义的子集已完成并接入真实链路——`Manager.collectAll()` 新旧画像对比产出变更事件(`DetectProfileChanges`)、`ProfileChangelogStore`(环形内存 + SQLite,30 天机会性清理)、`EventHub` 内存 pub/sub、`GET /api/health-center/changelog`(历史)+ `GET /api/health-center/events`(WebSocket 实时推送)、`AutoDiscoveryRunner` 发布 `discovery_completed`/`auto_mapping_applied`、前端 `ProfileChangelogTimeline.vue` 接入健康中心页并支持断线自动重连。均为只读展示,未修改 `SmartRouter`/`EndpointAttemptPolicy` 调度逻辑。落地时顺带发现并修复一个真实缺口:浏览器原生 WebSocket 无法设自定义 header,为此给 `middleware.getAPIKey` 增加了 `Sec-WebSocket-Protocol` 鉴权回退(业界标准做法,仅新增一个 key 来源,不降低现有校验)。\n\n**Phase 3B(已全部完成,2026-07-09)**:模型自动映射、自动恢复探测、晋升/降级机制会直接改变真实调度决策,风险明显高于 3A,四个子项已分批全部完成。§12.2 P1.5 的 SQLite schema version / 版本化 migration / `OriginType`&`OriginTier` 旧配置 backfill / `killSwitch`+按 task class/channel disable **已于 2026-07-09 补强完成**(详见 §12.2 P1.5)。**Phase 3B-1(自动恢复探测补全)已于 2026-07-09 完成**:修复了 L2 探测子系统里此前从未打通的两个真实 bug——① `collectAll()` 每轮把新构造的画像整行 Upsert,会把 `ProbeWorker` 刚写入的 `LastProbeAt`/`ProbeSuccess`/`ConsecutiveProbeSuccess` 等字段清零,导致 6 小时探测冷却期形同虚设(修复:`carryForwardProbeFields` 在 Upsert 前搬运 Probe* 字段,不影响 L1 诊断字段的现有覆盖逻辑);② `extractRawAPIKey` 是返回 `KeyMask` 掩码值的占位 stub,探测请求实际带的是假 key(修复:新增 `APIKeyResolver` 回调 + `Manager.ResolveAPIKey`,通过遍历配置里对应渠道的 `APIKeys` 逐个计算 `KeyHashFromAPIKey` 比对来还原明文;resolver 未注入或未命中时 fail-open 跳过探测且不消耗预算)。同时补齐了 `degraded/limited → healthy` 的连续成功恢复路径(新增 `KeyEndpointProfile.ConsecutiveProbeSuccess` 计数器 + `HealthCheckConfig.ProbeRecoveryThreshold`,默认连续 2 次探测成功才恢复,避免单次探测噪声导致状态抖动;探测失败计数器清零)。**Phase 3B-3(StabilityTier 晋降级滞后)已于 2026-07-09 完成**(提交 664d3fff):在原本每轮从瞬时 1h 窗口重算的无记忆 `StabilityTier` 上加一层连续窗口滞后——新增 `KeyEndpointProfile.EffectiveStabilityTier`/`StabilityPendingTier`/`StabilityPendingStreak` 三字段 + 纯函数 `applyStabilityHysteresis`(对称阈值:连续 N 轮同值才切换生效档,`HealthCheckConfig.StabilityHysteresisWindows` 默认 2;首次画像直接采用,不等待阈值,避免新渠道卡在零值),`AggregateChannelProfile` 改读 `EffectiveStabilityTier`(空则回退 `StabilityTier`)。晋升/降级机制的数据模型仅限于 StabilityTier,不改变 QualityTier/SpeedTier/CostTier 语义。**Phase 3B-2(模型自动映射闭环)已于 2026-07-09 完成**(提交 0f02e988,随后修复一处 critical 集成 bug,提交 856372c4):新建 `ModelProfileStore`(SQLite,`model_profiles` 表)+ 扩展 `AutoDiscoveryRunner` 为每个发现模型写入 `ModelProfile`(仅 `AutoManaged` 渠道);新建 `ModelResolver` 实现 §5.4 五步逻辑(手动 `RedirectModel` 短路 → `GetModelProfiles` → `filterByCapabilityFloor` → `rankBySimilarity`);`scheduler` 包新增 `ModelSupportResolverFunc` hook(fail-open,未注入或返回不支持时严格回退到原 `ExplainModelSupport`);`endpoint_policy.go` 的 `resolveMappedModel`(此前是永不触发的死代码——`profile.ModelMapping` 从未被写入)现调用 `ModelResolver`;`handlers/common` 在 `RedirectModel` 之后应用 `MappedModel` 覆盖请求体,并按 `EchoMappedModel` 配置回显 `X-CCX-Mapped-Model`/`X-CCX-Original-Model`/`X-CCX-Mapping-Source` 响应头。三条件安全门控:`ModelMapping.AutoResolve==true`(**默认由死配置的 true 改为 false**)AND `RoutingMode ∈ {assist, auto}` AND 无 `KillSwitch`,三者任一不满足则所有新增代码路径行为与今天字节级一致;且仅 `AutoManaged` 渠道触发,手动配置渠道完全不受影响。对抗性复核发现并修复一个真实 critical bug:`scoreEndpointForKey` 用空 `channelUID` 计算 `EndpointUID` 并写入 `modelByUID`,而 `handlers` 层用真实 `channelUID` 查 `modelByUID`,两者哈希永不相等导致 `MappedModel` 在 key 级调度路径上完全查不到(提交 856372c4 修复:命中画像后改用 `profile.EndpointUID`,新增回归测试锁定该不变量)。至此 Phase 3B 四个子项(自动恢复探测、StabilityTier 滞后、模型自动映射、active filter 前置判定)全部完成;SLO regression 自动回滚仍是独立子系统,留待后续单独立项。\n\n**范围**:\n- [x] 运行时指标驱动画像实时更新(Phase 3A:`collectAll()` 每轮 diff + changelog + WebSocket 推送)\n- [x] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)—— Phase 3B-2(2026-07-09 完成,提交 0f02e988 + 856372c4)\n- [x] active model filter / context filter 支持自动映射前置判定 —— Phase 3B-2:`scheduler.ModelSupportResolverFunc` fail-open 接入 `select.go`\n- [x] 自动恢复探测(limited/dead → healthy)—— Phase 3B-1(2026-07-09 完成):`APIKeyResolver` + `carryForwardProbeFields` + `ConsecutiveProbeSuccess`/`ProbeRecoveryThreshold`\n- [x] 晋升/降级机制(连续成功→升级,连续失败→降级)—— Phase 3B-3(2026-07-09 完成,提交 664d3fff):`StabilityTier` 连续窗口滞后(`EffectiveStabilityTier`),暂不含 QualityTier/SpeedTier/CostTier\n- [x] WebSocket 推送画像变更事件(Phase 3A:`GET /api/health-center/events`)\n- [x] 前端画像变更历史/时间线(Phase 3A:`ProfileChangelogTimeline.vue`)\n\n**预估工期**:2-3 周(3A、3B-1、3B-2、3B-3 均已完成;advisor 与全局 auto 的 SLO 回滚均已补齐)\n\n> 状态校正(2026-07-17):上方 Phase 3B 历史说明中的“SLO regression 自动回滚待单独立项”已完成;advisor 回滚与全局 auto 上线闸门/自动降级是两个独立保护层。\n\n### Phase 4:高级特性\n\n**Phase 4 状态(2026-07-09,全部完成)**:8 项全部实现并接入真实链路,均遵循\"默认关闭 + 显式 opt-in\"安全守则。\n\n**范围**:\n- [x] 多维度标签系统扩展(用户自定义标签)——Item 1\n- [x] 成本报表:按用户/模型/渠道/key 统计真实有效成本——Item 2(新增 `proxyKeyMask` 身份贯穿链路:`middleware.GetProxyKeyMask` → `ChannelLog`/`RequestRecord` → SQLite `proxy_key_mask` 列,供 Item 4 复用)\n- [x] Trusted advisor SLO regression 自动回滚——Item 3(默认关闭;`SLORollbackConfig`,连续 degrading 窗口数默认 3,不做自动恢复以避免震荡)\n- [x] 渠道推荐:根据使用模式推荐新渠道——Item 4\n- [x] 批量渠道管理(导入/导出/模板)——Item 5\n- [x] 订阅中心 provider adapter:可选自动刷新余额/套餐状态——Item 6(默认关闭;仅 openai/anthropic/google 且显式填写 BillingAPIKey 的订阅生效,中转/公益渠道继续手动维护)\n- [x] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板——Item 7\n- [x] A/B 测试:低比例统计抽样双发——Item 8(默认关闭,默认 1% 抽样 + 每小时 60 次硬预算;主请求路径不受影响,影子请求在主响应返回后异步发起)\n\n**关键实现说明**:\n- Item 3/6/8 均在 `AutopilotRoutingConfig` 新增独立配置块(`SLORollback`/`SubscriptionAutoRefresh`/`ABTest`),`Validate()` 统一做负值/越界兜底,保持与既有字段一致的防御模式。\n- Item 4 直接复用 Item 2 的 `proxyKeyMask` 身份标记,未重新发明身份识别机制。\n- **全局 auto 安全闸门(2026-07-17)**:请求终态按 15 分钟窗口无偏聚合,详细 trace 继续抽样;管理 API 只在 shadow/assist 已积累至少 500 个有效请求、24 小时连续观测且成功率/fallback/fail-open/p95 均达标时允许切换 `auto`。后台连续检测三个已完成 auto 窗口,相对最近七天安全模式基线持续恶化时,持久化降级到 `assist` 并记录 `autopilot_auto_safety_events`,不自动恢复。\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- ~~SQLite schema version 和幂等 migration。~~ **已完成**:`ensureSchemaVersion` 基于 `PRAGMA user_version`,当前 7 张表定版为 v1 基线;库版本高于代码版本时返回 error(fail-closed,不做任何写操作),交给下游 fail-open 分支接管。\n- ~~旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。~~ **已完成**:`ensureOriginBackfill` 只补 `\"unknown\"`,不做任何基于 URL/名称的猜测推断;已接线到 `Profiler.DeriveEndpointProfile`,`BreakTieByOriginTier` 现在能吃到真实数据(此前一直是 fallback 的 `unknown`)。\n- ~~profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。~~ **已确认满足并测试锁定**:`main.go` 现有结构本就是 fail-open(`NewProfileStore`/`NewManager` 出错只打日志、`autopilotManager` 保持 `nil`,下游全部有 `nil` 守卫),本次新增 `schema_migration_test.go` 用测试锁定这条不变量。\n- ~~全局 `killSwitch`、按 task class disable、按 channel disable。~~ **已完成**:新增 `AutopilotRoutingConfig.DisabledTaskClasses`/`DisabledChannelUIDs`;`CandidateFilterFor`/`executeFilter`/`BuildPlan` 三处一致接入,`smart_router_invariant_test.go` 覆盖\"task class 禁用后回退默认调度\"\"channel 禁用后不出现在候选里\"\"dry-run 与真实路径行为一致\"三条不变量。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。**待实现**:`TrustedRoutingAdvisor.promotionMode` 目前是 `manual`,shadow→candidate→active 已有人工审查关卡;自动回滚是独立的新子系统(需要基线捕获窗口 + 实时对比 + 阈值判定),留待下一轮单独设计落地。\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | shadow 样本/准确率/critical misroute 门槛;active 后 SLO regression 自动 rollback;全局 kill switch |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "e1c81c38d1d1dbf329226b12317baddf00d8e695": {"t": 1785759352.1077282, "n": 158080, "h": "2c83d781f0b7c754", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.2.3 同订阅多 Key:能力共享与配额负载均衡\n\n**问题**:官方订阅 plan(Claude Max、Codex plan 等)允许用户持有多个 key,或购买多份相同套餐。若每个 endpoint 独立探测与配置,探测预算被浪费、配置重复;请求量大时还需要在同 plan 的多个 key 之间按官方 RPM 限速自动负载均衡,而不是打满第一个 key 才溢出到下一个。\n\n**能力检测去重(同一套餐只需一套能力检测和配置)**:\n\n```text\nSubscriptionUID 相同的 endpoint 共享一份「订阅级能力画像」:\n 模型列表 / vision / tool / reasoning 支持 / 协议兼容开关 → 只探测一次,\n 存在 SubscriptionProfile.SharedCapability,endpoint 画像以引用方式继承。\n\n仍然 per-endpoint 维护的:健康状态、延迟、熔断、用量窗口\n (key 可能被单独封禁/限速,能力相同不代表运行态相同)\n\n继承失效条件:某 key 实测模型列表哈希与订阅级不一致(GroupChangeDetector 信号)\n → 该 key 脱离继承、独立探测,并在 UI 标记「与套餐能力不一致」警告。\n```\n\n**配额负载均衡(Phase 1 shadow 展示,Phase 2 生效)**:\n\n```text\n每 key 维护配额余量视图:\n remainingRPM = officialRPM − 当前窗口已用\n officialRPM 来源优先级:内置 manifest > 用户配置 > RateLimitDiscoverer 建议\n\n同订阅多 key 时按「剩余余量加权」分配请求(余量大者优先、\n窗口最早重置者兜底),替代顺序打满:\n 仅当渠道 autoManaged 且同一 SubscriptionUID 下 key 数 > 1 时启用;\n 否则保持现有 sequential/random key 顺序策略不变。\n\n某 key 的窗口配额耗尽 → 临时降权并切到同订阅其他 key,\n不熔断整个渠道(这是配额耗尽,不是故障)。\n```\n\n### 3.2.4 订阅用量窗口 (Usage Windows)\n\n部分官方 plan 提供 5h / 日 / 周 / 月等窗口的用量查询与限制(如 Claude 订阅的 5h 滚动窗口 + 周配额)。在 key 的位置展示这些窗口,帮助用户掌握使用情况:\n\n```go\ntype UsageWindow struct {\n Window string `json:\"window\"` // \"5h\" | \"day\" | \"week\" | \"month\"\n Used float64 `json:\"used\"` // 已用量\n Limit float64 `json:\"limit\"` // 上限,0 = 未知\n Unit string `json:\"unit\"` // requests | tokens | credits | percent\n ResetsAt time.Time `json:\"resetsAt\"` // 窗口重置时间\n Source string `json:\"source\"` // official_api | response_header | local_metering\n FetchedAt time.Time `json:\"fetchedAt\"`\n}\n// KeyEndpointProfile 与 SubscriptionProfile 均增加 UsageWindows []UsageWindow\n```\n\n数据来源优先级:\n\n```text\n1. 官方用量 API:有 provider adapter 的 plan 主动拉取(§Phase 4 订阅 adapter 的可提前子集)\n2. 响应头:anthropic-ratelimit-* / x-ratelimit-* 等(复用 RateLimitDiscoverer 的信号 tap)\n3. 本地计量兜底:CCX 按 key 累计请求数/token(误差来自窗口起点对齐,标注 Source=local_metering)\n```\n\nUI 要求:key 行内显示主窗口(5h)用量进度条,悬浮展开全部窗口;用量 >80% 黄色预警、超限红色并显示预计重置时间。调度联动(Phase 2):窗口余量进入配额负载均衡权重,窗口耗尽的 key 临时降权而非熔断。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n#### 4.2.1 内置模型清单 (Builtin Models Manifest)\n\n**问题**:部分官方订阅 plan 的入口没有正确的 models 接口——要么不存在(404)、要么需要额外权限、要么返回与订阅实际可用模型不符的列表(典型:Claude 订阅 OAuth 入口、Codex plan 入口、部分官方企业网关)。对这些 baseURL 做模型发现会失败或产出错误画像。\n\n**方案**:CCX 内置「已知官方 baseURL → 模型清单」映射表,命中时直接返回内置内容,完全跳过上游 models 接口:\n\n```go\n// backend-go/internal/config/builtin_models_manifest.go(生成式,随模型注册表更新)\ntype BuiltinModelsManifest struct {\n BaseURLPattern string `json:\"baseUrlPattern\"` // 精确 host 或前缀匹配,如 \"api.anthropic.com\"\n ServiceType string `json:\"serviceType\"` // messages/responses/…\n PlanHint string `json:\"planHint,omitempty\"` // 订阅类型提示(如 \"claude_subscription\")\n ModelIDs []string `json:\"modelIds\"` // 该入口实际可用的模型清单\n DisableProbe bool `json:\"disableProbe\"` // true 时 Discovery 跳过 GET /v1/models\n}\n```\n\n**两个生效位置**:\n\n```text\n1. Discovery(§4.2 流程第 2 步之前):\n baseURL 命中 manifest 且 DisableProbe=true\n → 跳过 GET /v1/models,直接用 ModelIDs 生成 ModelProfile[]\n → ProfileSource = \"builtin_manifest\",不再因 models 接口失败而误判 misconfigured\n\n2. /v1/models 代理响应(现有 Models API):\n 客户端向 CCX 请求 /v1/models 时,聚合结果中该渠道的贡献\n 直接取内置清单,不向上游发起 models 请求\n (渠道显式配置了 supportedModels 时仍以用户配置优先)\n```\n\n**维护与覆盖顺序**:\n\n```text\n渠道显式 supportedModels(用户手动) > 内置 manifest > 上游 models 接口实探 > 内置候选模型回退\n```\n\nmanifest 与模型注册表同源生成(`generated_model_registry.go` 同级),新模型发布时随注册表一起更新;HealthAnalyzer 对命中 manifest 的 endpoint 不将「models 接口 404/403」计入 misconfigured 信号。\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20;assist 下 dead/misconfigured 额外 -100 并保留在队尾\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法细分时进入 `general`;有规范 Knowledge 基准则使用其软评分,缺少直接证据时仍取 0.5 中性值。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n**手动 L3 第一版状态(2026-07-15)**:已实现 `pq-v1-20260715` 固定 JSON canary,覆盖\nMessages、Chat Completions、Responses 与 Gemini 上游协议。调用方只能指定 `endpointUid`、\n`modelId` 和 1~3 次重复数,不能注入 prompt。评分仍按 40% 完整性、30% 语义正确性、\n15% 严格 JSON 格式、15% 延迟执行;第一版使用非流式完整响应延迟作为首 token 延迟代理,\n后续引入流式采样时再替换该维度。单次有效样本 confidence=0.6,最多三次提升到 0.8;\n失败样本按 0 分计入均值,全部失败不写画像。\n\n安全边界:仅管理 API 手动触发,不启动自动 worker;当前进程按 UTC 日设置 12 次硬预算,\n请求前原子预留,单次 90 秒超时、响应体上限 64 KiB。同一 endpoint×model 不并发执行;\n单次请求最多允许 2048 个输出 token,以容纳推理模型的隐藏思考并限制最坏额度消耗。\n对声明可控推理的模型固定采用最低可用 effort;Claude 兼容端点若只暴露\n`reasoning_content` 而未声明 thinking 模式,则与 capability-test 一致显式关闭 thinking。\n原始模型输出和明文 Key 既不落盘也不返回,只保存分数、canary 版本、置信度和脱敏判定。\n`user_feedback` 仍高于 `probe`,自动发现重建 ModelProfile 时必须保留既有质量证据。\n跨进程共享预算与 LLM-as-judge/语义向量评分仍属于后续阶段。\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设 + 自定义\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n| `custom` | 用户自定义 | 用户自定义 | 高级用户微调,范围 0.0 ~ 3.0 |\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法细分的通用任务;缺少基准证据时中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general;有规范 Knowledge 基准时使用其软评分,缺少直接证据时取 0.5 中性。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? canonicalBenchmark[model][domain] * providerQualityFactor\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 规范模型基准 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n#### 5.7.4 规范模型能力基准\n\n`shared/model-registry/ccx_model_registry.json` 的 `benchmarkProfiles` 独立保存规范模型能力上界,不与 `upstreamCapabilities` 混合。后者仍是 context、vision、tool calls 等硬能力的事实源;benchmark 只参与 Autopilot 软评分和 trace 解释,绝不改变 `supportedModels` 或 CapabilityFloor。\n\n```text\nEffectiveCapability[domain]\n = CanonicalCeiling[model, domain]\n × ProviderQualityFactor[endpoint]\n\nProviderQualityFactor = 1 - confidence × (1 - providerQualityScore)\n无渠道质量证据或 confidence<0.5 时 factor=1;可信证据只能从规范模型上界向下折算。\n```\n\n基准数据必须保留领域向量、来源、核验日期、lane、共享结果数和可比类别覆盖率,不能只保存站点 Overall。当前 BenchLM 接入为 `provisional`:Claude Opus 4.8、GPT-5.6 Terra、GPT-5.6 Sol 各有 30 个共享结果,8 类中 5 类可比,核验日期为 2026-07-13。\n\n领域映射:Knowledge → `general`,Math → `reasoning`,Coding → `coding` / `code_review`,Agentic → `agentic`,Multimodal → `aesthetics_ui`(仅弱代理,解释置信度减半)。Writing / Translation 没有直接类别证据时继续回退家族种子或 0.5。BenchLM 的 cohort 归一化、缺失项插补和外部校准决定了 Overall 不是绝对能力值,因此路由只消费可映射的原始类别分。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**请求意图边界(ModelRoutingPolicy)**\n\n跨模型替代不是所有模型的默认能力,只对白名单客户端入口开放:\n\n```text\nmessages:\n fable / opus / sonnet / haiku 及对应 claude-* 完整模型名 → adaptive\nresponses:\n gpt-5.6* / gpt-5.5 / gpt-5.4* / codex-auto-review → adaptive\n其他协议或模型:\n exact-only,只能在探测到相同规范化模型 ID 的 endpoint 之间调度\n```\n\n`adaptive` 允许在满足 Capability Floor 后选择 GLM、MiMo 等其他模型族;`exact-only` 禁止跨模型替代。用户显式配置的 `modelMapping` 仍优先于该策略。调度预览和 endpoint 实际解析必须调用同一分类函数,避免诊断结果与真实请求分叉。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选最佳匹配\n // 匹配优先级:\n // a. 同模型族(opus→opus, sonnet→sonnet)—— 最高优先\n // b. 同质量档(premium→premium)\n // c. 上下文窗口最接近(不超也不差太多)\n // d. 探测延迟最低\n best := rankBySimilarity(eligible, requestModel, floor)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - autoManaged 的空 supportedModels 不表示支持全部,必须由 endpoint ModelProfile 裁决\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n当 ModelResolver 已正常初始化且路由处于 `assist/auto + autoResolve=true` 时,autoManaged 渠道的画像拒绝是权威结果,调度器不得再 fail-open 到空 `supportedModels` 的兼容语义。Resolver 不可用、开关关闭或 `off/shadow` 时仍保留原有 fail-open,保证急停和降级路径不依赖画像库。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\nGET /api/health-center/provider-quality/budget → 查看本进程手动 L3 每日预算\nPOST /api/health-center/provider-quality/probe → 对指定 endpoint×model 执行固定质量 canary\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n#### 8.0.1 实现现状与演进方向(2026-07-09 核实)\n\n四界面在 **Web 前端(`frontend/src`)已大面积落地**,不是纯文档:\n\n| 界面 | Web 实现 | Desktop 实现 |\n|------|----------|--------------|\n| 健康中心 | ✅ `HealthCenterView.vue` + `HealthCenterStats`/`HealthChannelTable`/`HealthChannelDetail`/`ProfileChangelogTimeline`,路由 `/health` | ❌ 无 |\n| 订阅中心 | ✅ `SubscriptionsView.vue` + `SubscriptionPlanTable`,CRUD + 刷新余额,路由 `/subscriptions` | ⚠️ 独立旧实现 `subscriptions/SubscriptionTab.vue`(与 Web 两套代码) |\n| 渠道中心 | ✅ `ChannelOrchestration.vue`(六态 `ChannelHealthBadge`、来源 chips、`autoManaged` 图标齐备) | ❌ 未同步 |\n| 管理面板 | ⚠️ `AutopilotModePanel.vue` 仅暴露 mode/killSwitch/costPreference | ❌ 无 |\n| 驾驶舱 | ✅ `CockpitView.vue`(观测型总览:健康统计+余额汇总+渠道推荐) | ⚠️ `cockpit` tab 渲染的是会话 Dashboard,职责不同 |\n\n**与本设计意图的关系**(对齐用户「自动调度为主、废旧手动驾驶舱」的诉求):\n- **旧的「手动调度驾驶舱」在 Web 端已不存在**。`CockpitView` 已是 §8.7 定义的观测型总览,只回答健康/省钱/风险/待办四问题,不做渠道配置编辑,符合「程序自动选、人只观测+有限干预」的方向。无需再废除,只需继续收敛为纯观测。\n- **统一提交入口已存在**(§8.4 快速添加 + §8.5 订阅中心),new-api 集成(§8.5.1)在此之上把「填 baseURL+key」进一步自动化。\n\n**前端演进待办**(本设计新增/明确):\n1. **补 `ManualRoutingIntent` 前端**:这是用户要的「有限偏向性干预」,后端已有、两个前端均未实现(§8.3 试用意图入口尚缺)。\n2. **Desktop 前端对齐 Web**:健康中心/渠道自动托管/管理面板/观测型驾驶舱在 Desktop 几乎空白,需同步或复用 Web 组件,避免长期维护两套。\n3. **管理面板补字段**:暴露 `l2ProbeEnabled`、探测预算、new-api/多来源订阅 adapter 开关(§8.5.2)等,当前只有 3 个字段。\n4. **订阅中心接 §8.5.1/§8.5.2**:新增「new-api」订阅类型与 Provider Adapter 能力矩阵驱动的动态表单。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.5.1 new-api 订阅集成(统一账号接入)\n\n**目标**:用户在订阅中心手填一个 new-api(及其 fork)账号的「系统访问令牌」即可,CCX 自动用该令牌创建代理专用 key、拉取分组倍率、查询额度余额,并把「站点 baseURL + 新建 key」自动落成一个上游渠道,交给 Autopilot 探测与调度。\n\n**范围约束**:\n- 只使用 new-api 用户在「个人设置」生成的 **系统访问令牌(access_token)** 一种凭据;令牌用于 API 调用的身份验证,属敏感数据,须妥善加密存储。\n- **不做** cookie 登录 + 2FA + 导出后台渠道明文 key 的管理员迁移路径(all-api-hub 的 newApiSession 能力),本设计不涉及。\n- new-api 归入 `OriginType=relay`、`OriginTier=second`(中转站),信任等级不因自动化提升;实时质量仍由运行画像决定。\n- 认证令牌像其他订阅凭据一样由用户手动录入,CCX 不代替用户在 new-api 侧注册或登录。\n\n**认证与凭据模型**:\n\nnew-api family 的管理接口鉴权(依据 all-api-hub 传输层实现):\n\n```text\nAuthorization: Bearer # 系统访问令牌;raw 模式可去掉 Bearer 前缀\nNew-API-User: # 当前用户 ID(必带)\nUser-id: # fork 兼容回退(Veloera/voapi/Super-API 等)\nContent-Type: application/json\n```\n\n统一响应信封:`{ \"success\": bool, \"data\": T, \"message\": string }`;解析时先判 `success`,再取 `data`。\n\n**SubscriptionProfile 扩展**:\n\n在 §3.2.2 的 `SubscriptionProfile` 基础上,为可自动接入的订阅新增 provider 集成字段(沿用「默认关闭 + 显式 opt-in」守则):\n\n```go\n// SubscriptionProfile 新增字段(复用 Phase 4 Item 6 的 SubscriptionAutoRefresh 开关)\ntype SubscriptionIntegration struct {\n Kind string `json:\"kind\"` // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | ...\n BaseURL string `json:\"baseUrl\"` // new-api 站点地址(同时作为新建渠道的上游 baseURL)\n AccessToken string `json:\"-\"` // 系统访问令牌;加密存储,不出 API 响应\n UserID string `json:\"userId\"` // New-API-User 头所需\n AuthTokenMode string `json:\"authTokenMode,omitempty\"` // \"bearer\"(默认) | \"raw\"\n // 自动建 key 的模板\n ProvisionKeyName string `json:\"provisionKeyName,omitempty\"` // 默认 \"ccx-autopilot\"\n ProvisionGroup string `json:\"provisionGroup,omitempty\"` // 建 key 时指定分组,空=默认分组\n ProvisionModels []string `json:\"provisionModels,omitempty\"` // model_limits,空=不限制\n // 自动同步产物(回填,只读展示)\n ProvisionedTokenID int `json:\"provisionedTokenId,omitempty\"`\n LinkedChannelUID string `json:\"linkedChannelUid,omitempty\"`\n}\n```\n\n`AccessToken` 必须与现有敏感字段一样走加密存储,序列化到前端时脱敏(仅显示尾部若干位)。\n\n**new-api 接口清单**(本集成实际调用的子集,路径以主线 new-api 为准,字段名保持与上游一致):\n\n| 用途 | 方法 | 路径 | 请求体 | 响应关键字段 |\n|------|------|------|--------|--------------|\n| 校验令牌 + 查用户/余额 | GET | `/api/user/self` | — | `data.id`、`data.username`、`data.quota`(剩余额度)、`data.used_quota` |\n| 拉分组倍率 | GET | `/api/user/self/groups` | — | `data: { : { desc, ratio } }` |\n| 站点全部分组 | GET | `/api/group` | — | `data: string[]` |\n| 账号可用模型 | GET | `/api/user/models` | — | `data: string[]` |\n| 创建代理 key | POST | `/api/token/` | `CreateTokenRequest`(见下) | `data.key` / `success` |\n| key 列表 | GET | `/api/token/?p={p}&size={n}` | — | `data.items[]`(含 `id/key/name/status/remain_quota`) |\n| 更新 key | PUT | `/api/token/` | `{ ...token, id }` | `success` |\n| 删除 key | DELETE | `/api/token/{id}` | — | `success` |\n\n`CreateTokenRequest`:\n\n```json\n{\n \"name\": \"ccx-autopilot\",\n \"remain_quota\": 0,\n \"expired_time\": -1,\n \"unlimited_quota\": true,\n \"model_limits_enabled\": false,\n \"model_limits\": \"\",\n \"allow_ips\": \"\",\n \"group\": \"\"\n}\n```\n\n`quota` 是 new-api 内部额度单位(非美元),换算比例读 `GET /api/status` 的 `price` / `PaymentUSDRate`(无鉴权),或允许用户在订阅中手填换算率;MVP 直接以 quota 原值展示 + 站点换算率估算余额。\n\n**接入流程**(订阅中心「新增订阅 → 类型选 new-api」):\n\n```text\n1. 用户输入:站点 baseURL + 系统访问令牌(access_token) + userId\n (userId 可选自动获取:先用令牌调 /api/user/self 拿 data.id 回填)\n2. 校验:GET /api/user/self\n ├─ success=false / 401 → 令牌无效或 userId 不匹配,报错停止\n └─ 成功 → 记录 username、quota,创建 SubscriptionProfile(provider=new_api, originTier=second)\n3. 拉分组:GET /api/user/self/groups → 写入 GroupMultipliers({group: ratio})\n GET /api/user/models → 记录账号可用模型,供渠道 supportedModels 参考\n4. 建 key:POST /api/token/(按 ProvisionKeyName/Group/Models 模板)\n ├─ 已存在同名 ccx-autopilot key → 复用其 id,不重复创建(列表比对 name)\n └─ 新建成功 → 回填 ProvisionedTokenID,取 data.key 作为渠道 apiKey\n5. 自动建渠道:用 baseURL + 新建 key 创建一个上游渠道\n ├─ channelKind 由用户选择或按站点探测(messages/chat/responses/gemini)\n ├─ status=unknown,autoManaged=true,OriginType=relay\n ├─ 建立 SubscriptionUID → ChannelUID 链接(回填 LinkedChannelUID)\n └─ 触发 §8.4 的 Discovery + 能力测试标准流程\n6. 完成:订阅卡片显示余额、分组倍率、已绑定渠道、探测进度\n```\n\n关键点:new-api 集成**不额外造探测逻辑**,第 5 步之后完全复用现有 §8.4「添加并探测」链路;集成层只负责「令牌 → key + 分组 + 余额 + baseURL」的自动填充,把手填 baseURL/key 的动作自动化掉。\n\n**刷新与同步**(复用 `POST /api/subscriptions/{uid}/refresh`):\n\n| 触发 | 动作 |\n|------|------|\n| 手动刷新余额 | `GET /api/user/self` 更新 `Balance` + `BalanceUpdatedAt` |\n| 定时刷新(可选,opt-in) | 复用 Phase 4 Item 6 `SubscriptionAutoRefresh`;new-api 作为 `relay` provider adapter 纳入,默认关闭 |\n| 分组倍率变更 | 刷新时对比 `/api/user/self/groups`,`GroupMultipliers` 变化 → 更新并提示「套餐倍率已变」 |\n| key 失效检测 | 渠道侧探测到该 key 全 401 → 订阅卡片提示「key 可能已删除/禁用」,提供「重新建 key」一键动作(重跑第 4-5 步,复用旧渠道,只换 apiKey) |\n| 可用模型变更 | `/api/user/models` 结果哈希变化 → 触发对应渠道重新 Discovery |\n\n**安全与边界**:\n- `access_token` 权限等同 new-api 账户,须加密存储、脱敏展示、禁止写入日志;刷新失败连续 N 次自动停用该订阅的自动刷新,只保留手动。\n- 自动建 key 默认 `unlimited_quota=true` 但可由用户改为限额模板;建 key 是有副作用的写操作,须在接入前明确告知用户「将在 new-api 侧创建一个名为 ccx-autopilot 的令牌」。\n- new-api 是 `OriginTier=second`,**不得**用作隐私敏感的 routing advisor / classifier(遵守 §3.2.1 约束)。\n- fork 兼容:请求同时带 `New-API-User` 与 `User-id` 头;响应信封字段名(`success/data/message`)在部分 fork 可能不同,adapter 需容错解析,失败时回退手动录入。\n- 一个 new-api 订阅默认只自动建/绑定一个渠道;用户如需该站多协议渠道,可在同订阅下手动增建,共用同一 key。\n\n### 8.5.2 订阅 Provider Adapter 框架(多来源余额与接入)\n\n§8.5.1 的 new-api 集成不是特例,而是「订阅 Provider Adapter」框架的一个实现。Phase 4 Item 6 已落地 openai/anthropic/google 的余额刷新 adapter,本框架把它抽象成统一接口,让 new-api 及后续来源(GitHub Copilot、Codex、Nvidia、MiniMax、DeepSeek 等)按相同契约接入,避免每加一个来源就散落一套逻辑(DRY)。\n\n**统一接口**:\n\n```go\n// backend-go/internal/autopilot/subscription/adapter.go\ntype ProviderAdapter interface {\n Kind() string // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | \"copilot\" | \"deepseek\" | ...\n\n // 校验凭据,返回账号身份(用户名/账户标识);失败即凭据无效\n Verify(ctx context.Context, cred Credential) (AccountInfo, error)\n // 查余额/额度;不支持返回 ErrUnsupported,由 UI 降级为手动录入\n FetchBalance(ctx context.Context, cred Credential) (Balance, error)\n // 拉分组倍率;仅 new-api 类中转有,官方直连返回 ErrUnsupported\n FetchGroups(ctx context.Context, cred Credential) (map[string]GroupRatio, error)\n // 自动创建代理 key;官方直连通常不支持(key 即用户 API key),返回 ErrUnsupported\n ProvisionKey(ctx context.Context, cred Credential, tmpl KeyTemplate) (ProvisionedKey, error)\n // 可用模型列表;用于渠道 supportedModels 参考\n FetchModels(ctx context.Context, cred Credential) ([]string, error)\n}\n```\n\n每个能力独立、可选:adapter 只实现自己支持的部分,不支持的统一返回 `ErrUnsupported`(接口隔离原则),UI 据此隐藏/降级对应入口。所有 adapter 共用 `SubscriptionAutoRefresh` 的 opt-in 开关与 `Validate()` 兜底,与 Phase 4 现有防御模式一致。\n\n**能力矩阵**(✅ 支持 / ⚠️ 部分或需确认 / ❌ 不支持,走手动):\n\n| 来源 | Kind | 验证 | 查余额 | 拉分组 | 自动建 key | OriginTier |\n|------|------|:----:|:------:|:------:|:----------:|------------|\n| new-api family | `new_api` | ✅ | ✅ quota | ✅ | ✅ | second |\n| OpenAI 官方 | `openai` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Anthropic 官方 | `anthropic` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Google 官方 | `google` | ✅ | ⚠️ | ❌ | ❌ | first |\n| GitHub Copilot | `copilot` | ⚠️ token | ❌ 无额度概念 | ❌ | ❌ | first |\n| Codex plan | `codex` | ⚠️ | ⚠️ 用量窗口 | ❌ | ❌ | first |\n| Nvidia NIM | `nvidia` | ✅ | ⚠️ credits | ❌ | ❌ | first |\n| MiniMax | `minimax` | ✅ | ⚠️ | ❌ | ❌ | first |\n| DeepSeek | `deepseek` | ✅ | ✅ balance API | ❌ | ❌ | first |\n\n说明:官方直连来源普遍**不支持自动建 key**(用户的 API key 本身就是凭据,直接作为渠道 apiKey 录入即可)、也**没有分组倍率**(价格按官方定价表)。它们的 adapter 价值集中在「验证 + 查余额/用量窗口」,把 §3.2.4 UsageWindow 的官方用量 API 来源坐实。DeepSeek 有独立余额查询端点,可完整支持余额。Copilot 属订阅制、无 token 余额概念,只做验证 + 用量提示。\n\n**接入分层**(YAGNI:不强求一次做全,按价值排期):\n\n```text\nP0(本轮):new_api(完整:验证+余额+分组+建key+自动建渠道)\n —— 覆盖用户最主要的中转站场景\nP1:deepseek(验证+余额)、官方三家余额已在 Phase 4 完成 → 补 UsageWindow 对齐\nP2:nvidia / minimax(验证+credits/余额)\nP3:copilot / codex(验证+用量窗口提示,无余额)\n```\n\n每个来源的具体端点在实现时于 `adapter.go` 各 provider 文件内落地,本设计只固定接口契约与能力矩阵,避免文档与上游 API 变动强耦合。新增来源 = 实现 `ProviderAdapter` + 在能力矩阵登记,不改框架。\n\n**能力缺失与降级**:\n- adapter 任一能力返回 `ErrUnsupported` → 订阅中心对应字段回退为手动录入(余额手填、倍率手填),不报错、不阻塞接入。\n- adapter 验证失败/超时 → 订阅可仍以「手动模式」创建,仅失去自动刷新与自动建 key,其余流程(手填 baseURL+key 建渠道)不受影响。\n- 官方来源无「自动建 key」时,接入流程退化为 §8.4 标准快速添加:用户填 API key,adapter 只补验证与余额。\n- 所有自动刷新遵守 `SubscriptionAutoRefresh` 默认关闭;连续失败自动停用自动刷新并在驾驶舱「人工待办」提示。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3,\n \"providerTimePricing\": {\n \"deepseek\": {\n \"effectiveFrom\": \"2026-07-20T00:00:00+08:00\",\n \"timeZone\": \"Asia/Shanghai\",\n \"peakMultiplier\": 2,\n \"peakWindows\": [\n { \"start\": \"09:00\", \"end\": \"12:00\" },\n { \"start\": \"14:00\", \"end\": \"18:00\" }\n ]\n }\n }\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n`providerTimePricing.effectiveFrom` 使用 RFC3339;修改该值即可调整激活时间,设为空字符串则停用对应规则。\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**Phase 3A 状态(2026-07-09)**:低风险、不改真实路由语义的子集已完成并接入真实链路——`Manager.collectAll()` 新旧画像对比产出变更事件(`DetectProfileChanges`)、`ProfileChangelogStore`(环形内存 + SQLite,30 天机会性清理)、`EventHub` 内存 pub/sub、`GET /api/health-center/changelog`(历史)+ `GET /api/health-center/events`(WebSocket 实时推送)、`AutoDiscoveryRunner` 发布 `discovery_completed`/`auto_mapping_applied`、前端 `ProfileChangelogTimeline.vue` 接入健康中心页并支持断线自动重连。均为只读展示,未修改 `SmartRouter`/`EndpointAttemptPolicy` 调度逻辑。落地时顺带发现并修复一个真实缺口:浏览器原生 WebSocket 无法设自定义 header,为此给 `middleware.getAPIKey` 增加了 `Sec-WebSocket-Protocol` 鉴权回退(业界标准做法,仅新增一个 key 来源,不降低现有校验)。\n\n**Phase 3B(已全部完成,2026-07-09)**:模型自动映射、自动恢复探测、晋升/降级机制会直接改变真实调度决策,风险明显高于 3A,四个子项已分批全部完成。§12.2 P1.5 的 SQLite schema version / 版本化 migration / `OriginType`&`OriginTier` 旧配置 backfill / `killSwitch`+按 task class/channel disable **已于 2026-07-09 补强完成**(详见 §12.2 P1.5)。**Phase 3B-1(自动恢复探测补全)已于 2026-07-09 完成**:修复了 L2 探测子系统里此前从未打通的两个真实 bug——① `collectAll()` 每轮把新构造的画像整行 Upsert,会把 `ProbeWorker` 刚写入的 `LastProbeAt`/`ProbeSuccess`/`ConsecutiveProbeSuccess` 等字段清零,导致 6 小时探测冷却期形同虚设(修复:`carryForwardProbeFields` 在 Upsert 前搬运 Probe* 字段,不影响 L1 诊断字段的现有覆盖逻辑);② `extractRawAPIKey` 是返回 `KeyMask` 掩码值的占位 stub,探测请求实际带的是假 key(修复:新增 `APIKeyResolver` 回调 + `Manager.ResolveAPIKey`,通过遍历配置里对应渠道的 `APIKeys` 逐个计算 `KeyHashFromAPIKey` 比对来还原明文;resolver 未注入或未命中时 fail-open 跳过探测且不消耗预算)。同时补齐了 `degraded/limited → healthy` 的连续成功恢复路径(新增 `KeyEndpointProfile.ConsecutiveProbeSuccess` 计数器 + `HealthCheckConfig.ProbeRecoveryThreshold`,默认连续 2 次探测成功才恢复,避免单次探测噪声导致状态抖动;探测失败计数器清零)。**Phase 3B-3(StabilityTier 晋降级滞后)已于 2026-07-09 完成**(提交 664d3fff):在原本每轮从瞬时 1h 窗口重算的无记忆 `StabilityTier` 上加一层连续窗口滞后——新增 `KeyEndpointProfile.EffectiveStabilityTier`/`StabilityPendingTier`/`StabilityPendingStreak` 三字段 + 纯函数 `applyStabilityHysteresis`(对称阈值:连续 N 轮同值才切换生效档,`HealthCheckConfig.StabilityHysteresisWindows` 默认 2;首次画像直接采用,不等待阈值,避免新渠道卡在零值),`AggregateChannelProfile` 改读 `EffectiveStabilityTier`(空则回退 `StabilityTier`)。晋升/降级机制的数据模型仅限于 StabilityTier,不改变 QualityTier/SpeedTier/CostTier 语义。**Phase 3B-2(模型自动映射闭环)已于 2026-07-09 完成**(提交 0f02e988,随后修复一处 critical 集成 bug,提交 856372c4):新建 `ModelProfileStore`(SQLite,`model_profiles` 表)+ 扩展 `AutoDiscoveryRunner` 为每个发现模型写入 `ModelProfile`(仅 `AutoManaged` 渠道);新建 `ModelResolver` 实现 §5.4 五步逻辑(手动 `RedirectModel` 短路 → `GetModelProfiles` → `filterByCapabilityFloor` → `rankBySimilarity`);`scheduler` 包新增 `ModelSupportResolverFunc` hook(fail-open,未注入或返回不支持时严格回退到原 `ExplainModelSupport`);`endpoint_policy.go` 的 `resolveMappedModel`(此前是永不触发的死代码——`profile.ModelMapping` 从未被写入)现调用 `ModelResolver`;`handlers/common` 在 `RedirectModel` 之后应用 `MappedModel` 覆盖请求体,并按 `EchoMappedModel` 配置回显 `X-CCX-Mapped-Model`/`X-CCX-Original-Model`/`X-CCX-Mapping-Source` 响应头。三条件安全门控:`ModelMapping.AutoResolve==true`(**默认由死配置的 true 改为 false**)AND `RoutingMode ∈ {assist, auto}` AND 无 `KillSwitch`,三者任一不满足则所有新增代码路径行为与今天字节级一致;且仅 `AutoManaged` 渠道触发,手动配置渠道完全不受影响。对抗性复核发现并修复一个真实 critical bug:`scoreEndpointForKey` 用空 `channelUID` 计算 `EndpointUID` 并写入 `modelByUID`,而 `handlers` 层用真实 `channelUID` 查 `modelByUID`,两者哈希永不相等导致 `MappedModel` 在 key 级调度路径上完全查不到(提交 856372c4 修复:命中画像后改用 `profile.EndpointUID`,新增回归测试锁定该不变量)。至此 Phase 3B 四个子项(自动恢复探测、StabilityTier 滞后、模型自动映射、active filter 前置判定)全部完成;SLO regression 自动回滚仍是独立子系统,留待后续单独立项。\n\n**范围**:\n- [x] 运行时指标驱动画像实时更新(Phase 3A:`collectAll()` 每轮 diff + changelog + WebSocket 推送)\n- [x] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)—— Phase 3B-2(2026-07-09 完成,提交 0f02e988 + 856372c4)\n- [x] active model filter / context filter 支持自动映射前置判定 —— Phase 3B-2:`scheduler.ModelSupportResolverFunc` fail-open 接入 `select.go`\n- [x] 自动恢复探测(limited/dead → healthy)—— Phase 3B-1(2026-07-09 完成):`APIKeyResolver` + `carryForwardProbeFields` + `ConsecutiveProbeSuccess`/`ProbeRecoveryThreshold`\n- [x] 晋升/降级机制(连续成功→升级,连续失败→降级)—— Phase 3B-3(2026-07-09 完成,提交 664d3fff):`StabilityTier` 连续窗口滞后(`EffectiveStabilityTier`),暂不含 QualityTier/SpeedTier/CostTier\n- [x] WebSocket 推送画像变更事件(Phase 3A:`GET /api/health-center/events`)\n- [x] 前端画像变更历史/时间线(Phase 3A:`ProfileChangelogTimeline.vue`)\n\n**预估工期**:2-3 周(3A、3B-1、3B-2、3B-3 均已完成;advisor 与全局 auto 的 SLO 回滚均已补齐)\n\n> 状态校正(2026-07-17):上方 Phase 3B 历史说明中的“SLO regression 自动回滚待单独立项”已完成;advisor 回滚与全局 auto 上线闸门/自动降级是两个独立保护层。\n\n### Phase 4:高级特性\n\n**Phase 4 状态(2026-07-09,全部完成)**:8 项全部实现并接入真实链路,均遵循\"默认关闭 + 显式 opt-in\"安全守则。\n\n**范围**:\n- [x] 多维度标签系统扩展(用户自定义标签)——Item 1\n- [x] 成本报表:按用户/模型/渠道/key 统计真实有效成本——Item 2(新增 `proxyKeyMask` 身份贯穿链路:`middleware.GetProxyKeyMask` → `ChannelLog`/`RequestRecord` → SQLite `proxy_key_mask` 列,供 Item 4 复用)\n- [x] Trusted advisor SLO regression 自动回滚——Item 3(默认关闭;`SLORollbackConfig`,连续 degrading 窗口数默认 3,不做自动恢复以避免震荡)\n- [x] 渠道推荐:根据使用模式推荐新渠道——Item 4\n- [x] 批量渠道管理(导入/导出/模板)——Item 5\n- [x] 订阅中心 provider adapter:可选自动刷新余额/套餐状态——Item 6(默认关闭;仅 openai/anthropic/google 且显式填写 BillingAPIKey 的订阅生效,中转/公益渠道继续手动维护)\n- [x] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板——Item 7\n- [x] A/B 测试:低比例统计抽样双发——Item 8(默认关闭,默认 1% 抽样 + 每小时 60 次硬预算;主请求路径不受影响,影子请求在主响应返回后异步发起)\n\n**关键实现说明**:\n- Item 3/6/8 均在 `AutopilotRoutingConfig` 新增独立配置块(`SLORollback`/`SubscriptionAutoRefresh`/`ABTest`),`Validate()` 统一做负值/越界兜底,保持与既有字段一致的防御模式。\n- Item 4 直接复用 Item 2 的 `proxyKeyMask` 身份标记,未重新发明身份识别机制。\n- **全局 auto 安全闸门(2026-07-17)**:请求终态按 15 分钟窗口无偏聚合,详细 trace 继续抽样;管理 API 只在 shadow/assist 已积累至少 500 个有效请求、24 小时连续观测且成功率/fallback/fail-open/p95 均达标时允许切换 `auto`。后台连续检测三个已完成 auto 窗口,相对最近七天安全模式基线持续恶化时,持久化降级到 `assist` 并记录 `autopilot_auto_safety_events`,不自动恢复。\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- ~~SQLite schema version 和幂等 migration。~~ **已完成**:`ensureSchemaVersion` 基于 `PRAGMA user_version`,当前 7 张表定版为 v1 基线;库版本高于代码版本时返回 error(fail-closed,不做任何写操作),交给下游 fail-open 分支接管。\n- ~~旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。~~ **已完成**:`ensureOriginBackfill` 只补 `\"unknown\"`,不做任何基于 URL/名称的猜测推断;已接线到 `Profiler.DeriveEndpointProfile`,`BreakTieByOriginTier` 现在能吃到真实数据(此前一直是 fallback 的 `unknown`)。\n- ~~profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。~~ **已确认满足并测试锁定**:`main.go` 现有结构本就是 fail-open(`NewProfileStore`/`NewManager` 出错只打日志、`autopilotManager` 保持 `nil`,下游全部有 `nil` 守卫),本次新增 `schema_migration_test.go` 用测试锁定这条不变量。\n- ~~全局 `killSwitch`、按 task class disable、按 channel disable。~~ **已完成**:新增 `AutopilotRoutingConfig.DisabledTaskClasses`/`DisabledChannelUIDs`;`CandidateFilterFor`/`executeFilter`/`BuildPlan` 三处一致接入,`smart_router_invariant_test.go` 覆盖\"task class 禁用后回退默认调度\"\"channel 禁用后不出现在候选里\"\"dry-run 与真实路径行为一致\"三条不变量。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。**待实现**:`TrustedRoutingAdvisor.promotionMode` 目前是 `manual`,shadow→candidate→active 已有人工审查关卡;自动回滚是独立的新子系统(需要基线捕获窗口 + 实时对比 + 阈值判定),留待下一轮单独设计落地。\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | shadow 样本/准确率/critical misroute 门槛;active 后 SLO regression 自动 rollback;全局 kill switch |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "99dce01ba7be2167bec8bcd06fdf4e5c7c1d627e": {"t": 1785759354.8394003, "n": 157705, "h": "195d0f209ef732db", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.2.3 同订阅多 Key:能力共享与配额负载均衡\n\n**问题**:官方订阅 plan(Claude Max、Codex plan 等)允许用户持有多个 key,或购买多份相同套餐。若每个 endpoint 独立探测与配置,探测预算被浪费、配置重复;请求量大时还需要在同 plan 的多个 key 之间按官方 RPM 限速自动负载均衡,而不是打满第一个 key 才溢出到下一个。\n\n**能力检测去重(同一套餐只需一套能力检测和配置)**:\n\n```text\nSubscriptionUID 相同的 endpoint 共享一份「订阅级能力画像」:\n 模型列表 / vision / tool / reasoning 支持 / 协议兼容开关 → 只探测一次,\n 存在 SubscriptionProfile.SharedCapability,endpoint 画像以引用方式继承。\n\n仍然 per-endpoint 维护的:健康状态、延迟、熔断、用量窗口\n (key 可能被单独封禁/限速,能力相同不代表运行态相同)\n\n继承失效条件:某 key 实测模型列表哈希与订阅级不一致(GroupChangeDetector 信号)\n → 该 key 脱离继承、独立探测,并在 UI 标记「与套餐能力不一致」警告。\n```\n\n**配额负载均衡(Phase 1 shadow 展示,Phase 2 生效)**:\n\n```text\n每 key 维护配额余量视图:\n remainingRPM = officialRPM − 当前窗口已用\n officialRPM 来源优先级:内置 manifest > 用户配置 > RateLimitDiscoverer 建议\n\n同订阅多 key 时按「剩余余量加权」分配请求(余量大者优先、\n窗口最早重置者兜底),替代顺序打满:\n 仅当渠道 autoManaged 且同一 SubscriptionUID 下 key 数 > 1 时启用;\n 否则保持现有 sequential/random key 顺序策略不变。\n\n某 key 的窗口配额耗尽 → 临时降权并切到同订阅其他 key,\n不熔断整个渠道(这是配额耗尽,不是故障)。\n```\n\n### 3.2.4 订阅用量窗口 (Usage Windows)\n\n部分官方 plan 提供 5h / 日 / 周 / 月等窗口的用量查询与限制(如 Claude 订阅的 5h 滚动窗口 + 周配额)。在 key 的位置展示这些窗口,帮助用户掌握使用情况:\n\n```go\ntype UsageWindow struct {\n Window string `json:\"window\"` // \"5h\" | \"day\" | \"week\" | \"month\"\n Used float64 `json:\"used\"` // 已用量\n Limit float64 `json:\"limit\"` // 上限,0 = 未知\n Unit string `json:\"unit\"` // requests | tokens | credits | percent\n ResetsAt time.Time `json:\"resetsAt\"` // 窗口重置时间\n Source string `json:\"source\"` // official_api | response_header | local_metering\n FetchedAt time.Time `json:\"fetchedAt\"`\n}\n// KeyEndpointProfile 与 SubscriptionProfile 均增加 UsageWindows []UsageWindow\n```\n\n数据来源优先级:\n\n```text\n1. 官方用量 API:有 provider adapter 的 plan 主动拉取(§Phase 4 订阅 adapter 的可提前子集)\n2. 响应头:anthropic-ratelimit-* / x-ratelimit-* 等(复用 RateLimitDiscoverer 的信号 tap)\n3. 本地计量兜底:CCX 按 key 累计请求数/token(误差来自窗口起点对齐,标注 Source=local_metering)\n```\n\nUI 要求:key 行内显示主窗口(5h)用量进度条,悬浮展开全部窗口;用量 >80% 黄色预警、超限红色并显示预计重置时间。调度联动(Phase 2):窗口余量进入配额负载均衡权重,窗口耗尽的 key 临时降权而非熔断。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n#### 4.2.1 内置模型清单 (Builtin Models Manifest)\n\n**问题**:部分官方订阅 plan 的入口没有正确的 models 接口——要么不存在(404)、要么需要额外权限、要么返回与订阅实际可用模型不符的列表(典型:Claude 订阅 OAuth 入口、Codex plan 入口、部分官方企业网关)。对这些 baseURL 做模型发现会失败或产出错误画像。\n\n**方案**:CCX 内置「已知官方 baseURL → 模型清单」映射表,命中时直接返回内置内容,完全跳过上游 models 接口:\n\n```go\n// backend-go/internal/config/builtin_models_manifest.go(生成式,随模型注册表更新)\ntype BuiltinModelsManifest struct {\n BaseURLPattern string `json:\"baseUrlPattern\"` // 精确 host 或前缀匹配,如 \"api.anthropic.com\"\n ServiceType string `json:\"serviceType\"` // messages/responses/…\n PlanHint string `json:\"planHint,omitempty\"` // 订阅类型提示(如 \"claude_subscription\")\n ModelIDs []string `json:\"modelIds\"` // 该入口实际可用的模型清单\n DisableProbe bool `json:\"disableProbe\"` // true 时 Discovery 跳过 GET /v1/models\n}\n```\n\n**两个生效位置**:\n\n```text\n1. Discovery(§4.2 流程第 2 步之前):\n baseURL 命中 manifest 且 DisableProbe=true\n → 跳过 GET /v1/models,直接用 ModelIDs 生成 ModelProfile[]\n → ProfileSource = \"builtin_manifest\",不再因 models 接口失败而误判 misconfigured\n\n2. /v1/models 代理响应(现有 Models API):\n 客户端向 CCX 请求 /v1/models 时,聚合结果中该渠道的贡献\n 直接取内置清单,不向上游发起 models 请求\n (渠道显式配置了 supportedModels 时仍以用户配置优先)\n```\n\n**维护与覆盖顺序**:\n\n```text\n渠道显式 supportedModels(用户手动) > 内置 manifest > 上游 models 接口实探 > 内置候选模型回退\n```\n\nmanifest 与模型注册表同源生成(`generated_model_registry.go` 同级),新模型发布时随注册表一起更新;HealthAnalyzer 对命中 manifest 的 endpoint 不将「models 接口 404/403」计入 misconfigured 信号。\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20;assist 下 dead/misconfigured 额外 -100 并保留在队尾\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法细分时进入 `general`;有规范 Knowledge 基准则使用其软评分,缺少直接证据时仍取 0.5 中性值。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n**手动 L3 第一版状态(2026-07-15)**:已实现 `pq-v1-20260715` 固定 JSON canary,覆盖\nMessages、Chat Completions、Responses 与 Gemini 上游协议。调用方只能指定 `endpointUid`、\n`modelId` 和 1~3 次重复数,不能注入 prompt。评分仍按 40% 完整性、30% 语义正确性、\n15% 严格 JSON 格式、15% 延迟执行;第一版使用非流式完整响应延迟作为首 token 延迟代理,\n后续引入流式采样时再替换该维度。单次有效样本 confidence=0.6,最多三次提升到 0.8;\n失败样本按 0 分计入均值,全部失败不写画像。\n\n安全边界:仅管理 API 手动触发,不启动自动 worker;当前进程按 UTC 日设置 12 次硬预算,\n请求前原子预留,单次 90 秒超时、响应体上限 64 KiB。同一 endpoint×model 不并发执行;\n单次请求最多允许 2048 个输出 token,以容纳推理模型的隐藏思考并限制最坏额度消耗。\n对声明可控推理的模型固定采用最低可用 effort;Claude 兼容端点若只暴露\n`reasoning_content` 而未声明 thinking 模式,则与 capability-test 一致显式关闭 thinking。\n原始模型输出和明文 Key 既不落盘也不返回,只保存分数、canary 版本、置信度和脱敏判定。\n`user_feedback` 仍高于 `probe`,自动发现重建 ModelProfile 时必须保留既有质量证据。\n跨进程共享预算与 LLM-as-judge/语义向量评分仍属于后续阶段。\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设 + 自定义\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n| `custom` | 用户自定义 | 用户自定义 | 高级用户微调,范围 0.0 ~ 3.0 |\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法细分的通用任务;缺少基准证据时中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general;有规范 Knowledge 基准时使用其软评分,缺少直接证据时取 0.5 中性。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? canonicalBenchmark[model][domain] * providerQualityFactor\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 规范模型基准 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n#### 5.7.4 规范模型能力基准\n\n`shared/model-registry/ccx_model_registry.json` 的 `benchmarkProfiles` 独立保存规范模型能力上界,不与 `upstreamCapabilities` 混合。后者仍是 context、vision、tool calls 等硬能力的事实源;benchmark 只参与 Autopilot 软评分和 trace 解释,绝不改变 `supportedModels` 或 CapabilityFloor。\n\n```text\nEffectiveCapability[domain]\n = CanonicalCeiling[model, domain]\n × ProviderQualityFactor[endpoint]\n\nProviderQualityFactor = 1 - confidence × (1 - providerQualityScore)\n无渠道质量证据或 confidence<0.5 时 factor=1;可信证据只能从规范模型上界向下折算。\n```\n\n基准数据必须保留领域向量、来源、核验日期、lane、共享结果数和可比类别覆盖率,不能只保存站点 Overall。当前 BenchLM 接入为 `provisional`:Claude Opus 4.8、GPT-5.6 Terra、GPT-5.6 Sol 各有 30 个共享结果,8 类中 5 类可比,核验日期为 2026-07-13。\n\n领域映射:Knowledge → `general`,Math → `reasoning`,Coding → `coding` / `code_review`,Agentic → `agentic`,Multimodal → `aesthetics_ui`(仅弱代理,解释置信度减半)。Writing / Translation 没有直接类别证据时继续回退家族种子或 0.5。BenchLM 的 cohort 归一化、缺失项插补和外部校准决定了 Overall 不是绝对能力值,因此路由只消费可映射的原始类别分。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**请求意图边界(ModelRoutingPolicy)**\n\n跨模型替代不是所有模型的默认能力,只对白名单客户端入口开放:\n\n```text\nmessages:\n fable / opus / sonnet / haiku 及对应 claude-* 完整模型名 → adaptive\nresponses:\n gpt-5.6* / gpt-5.5 / gpt-5.4* / codex-auto-review → adaptive\n其他协议或模型:\n exact-only,只能在探测到相同规范化模型 ID 的 endpoint 之间调度\n```\n\n`adaptive` 允许在满足 Capability Floor 后选择 GLM、MiMo 等其他模型族;`exact-only` 禁止跨模型替代。用户显式配置的 `modelMapping` 仍优先于该策略。调度预览和 endpoint 实际解析必须调用同一分类函数,避免诊断结果与真实请求分叉。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选最佳匹配\n // 匹配优先级:\n // a. 同模型族(opus→opus, sonnet→sonnet)—— 最高优先\n // b. 同质量档(premium→premium)\n // c. 上下文窗口最接近(不超也不差太多)\n // d. 探测延迟最低\n best := rankBySimilarity(eligible, requestModel, floor)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - autoManaged 的空 supportedModels 不表示支持全部,必须由 endpoint ModelProfile 裁决\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n当 ModelResolver 已正常初始化且路由处于 `assist/auto + autoResolve=true` 时,autoManaged 渠道的画像拒绝是权威结果,调度器不得再 fail-open 到空 `supportedModels` 的兼容语义。Resolver 不可用、开关关闭或 `off/shadow` 时仍保留原有 fail-open,保证急停和降级路径不依赖画像库。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\nGET /api/health-center/provider-quality/budget → 查看本进程手动 L3 每日预算\nPOST /api/health-center/provider-quality/probe → 对指定 endpoint×model 执行固定质量 canary\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n#### 8.0.1 实现现状与演进方向(2026-07-09 核实)\n\n四界面在 **Web 前端(`frontend/src`)已大面积落地**,不是纯文档:\n\n| 界面 | Web 实现 | Desktop 实现 |\n|------|----------|--------------|\n| 健康中心 | ✅ `HealthCenterView.vue` + `HealthCenterStats`/`HealthChannelTable`/`HealthChannelDetail`/`ProfileChangelogTimeline`,路由 `/health` | ❌ 无 |\n| 订阅中心 | ✅ `SubscriptionsView.vue` + `SubscriptionPlanTable`,CRUD + 刷新余额,路由 `/subscriptions` | ⚠️ 独立旧实现 `subscriptions/SubscriptionTab.vue`(与 Web 两套代码) |\n| 渠道中心 | ✅ `ChannelOrchestration.vue`(六态 `ChannelHealthBadge`、来源 chips、`autoManaged` 图标齐备) | ❌ 未同步 |\n| 管理面板 | ⚠️ `AutopilotModePanel.vue` 仅暴露 mode/killSwitch/costPreference | ❌ 无 |\n| 驾驶舱 | ✅ `CockpitView.vue`(观测型总览:健康统计+余额汇总+渠道推荐) | ⚠️ `cockpit` tab 渲染的是会话 Dashboard,职责不同 |\n\n**与本设计意图的关系**(对齐用户「自动调度为主、废旧手动驾驶舱」的诉求):\n- **旧的「手动调度驾驶舱」在 Web 端已不存在**。`CockpitView` 已是 §8.7 定义的观测型总览,只回答健康/省钱/风险/待办四问题,不做渠道配置编辑,符合「程序自动选、人只观测+有限干预」的方向。无需再废除,只需继续收敛为纯观测。\n- **统一提交入口已存在**(§8.4 快速添加 + §8.5 订阅中心),new-api 集成(§8.5.1)在此之上把「填 baseURL+key」进一步自动化。\n\n**前端演进待办**(本设计新增/明确):\n1. **补 `ManualRoutingIntent` 前端**:这是用户要的「有限偏向性干预」,后端已有、两个前端均未实现(§8.3 试用意图入口尚缺)。\n2. **Desktop 前端对齐 Web**:健康中心/渠道自动托管/管理面板/观测型驾驶舱在 Desktop 几乎空白,需同步或复用 Web 组件,避免长期维护两套。\n3. **管理面板补字段**:暴露 `l2ProbeEnabled`、探测预算、new-api/多来源订阅 adapter 开关(§8.5.2)等,当前只有 3 个字段。\n4. **订阅中心接 §8.5.1/§8.5.2**:新增「new-api」订阅类型与 Provider Adapter 能力矩阵驱动的动态表单。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.5.1 new-api 订阅集成(统一账号接入)\n\n**目标**:用户在订阅中心手填一个 new-api(及其 fork)账号的「系统访问令牌」即可,CCX 自动用该令牌创建代理专用 key、拉取分组倍率、查询额度余额,并把「站点 baseURL + 新建 key」自动落成一个上游渠道,交给 Autopilot 探测与调度。\n\n**范围约束**:\n- 只使用 new-api 用户在「个人设置」生成的 **系统访问令牌(access_token)** 一种凭据;令牌用于 API 调用的身份验证,属敏感数据,须妥善加密存储。\n- **不做** cookie 登录 + 2FA + 导出后台渠道明文 key 的管理员迁移路径(all-api-hub 的 newApiSession 能力),本设计不涉及。\n- new-api 归入 `OriginType=relay`、`OriginTier=second`(中转站),信任等级不因自动化提升;实时质量仍由运行画像决定。\n- 认证令牌像其他订阅凭据一样由用户手动录入,CCX 不代替用户在 new-api 侧注册或登录。\n\n**认证与凭据模型**:\n\nnew-api family 的管理接口鉴权(依据 all-api-hub 传输层实现):\n\n```text\nAuthorization: Bearer # 系统访问令牌;raw 模式可去掉 Bearer 前缀\nNew-API-User: # 当前用户 ID(必带)\nUser-id: # fork 兼容回退(Veloera/voapi/Super-API 等)\nContent-Type: application/json\n```\n\n统一响应信封:`{ \"success\": bool, \"data\": T, \"message\": string }`;解析时先判 `success`,再取 `data`。\n\n**SubscriptionProfile 扩展**:\n\n在 §3.2.2 的 `SubscriptionProfile` 基础上,为可自动接入的订阅新增 provider 集成字段(沿用「默认关闭 + 显式 opt-in」守则):\n\n```go\n// SubscriptionProfile 新增字段(复用 Phase 4 Item 6 的 SubscriptionAutoRefresh 开关)\ntype SubscriptionIntegration struct {\n Kind string `json:\"kind\"` // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | ...\n BaseURL string `json:\"baseUrl\"` // new-api 站点地址(同时作为新建渠道的上游 baseURL)\n AccessToken string `json:\"-\"` // 系统访问令牌;加密存储,不出 API 响应\n UserID string `json:\"userId\"` // New-API-User 头所需\n AuthTokenMode string `json:\"authTokenMode,omitempty\"` // \"bearer\"(默认) | \"raw\"\n // 自动建 key 的模板\n ProvisionKeyName string `json:\"provisionKeyName,omitempty\"` // 默认 \"ccx-autopilot\"\n ProvisionGroup string `json:\"provisionGroup,omitempty\"` // 建 key 时指定分组,空=默认分组\n ProvisionModels []string `json:\"provisionModels,omitempty\"` // model_limits,空=不限制\n // 自动同步产物(回填,只读展示)\n ProvisionedTokenID int `json:\"provisionedTokenId,omitempty\"`\n LinkedChannelUID string `json:\"linkedChannelUid,omitempty\"`\n}\n```\n\n`AccessToken` 必须与现有敏感字段一样走加密存储,序列化到前端时脱敏(仅显示尾部若干位)。\n\n**new-api 接口清单**(本集成实际调用的子集,路径以主线 new-api 为准,字段名保持与上游一致):\n\n| 用途 | 方法 | 路径 | 请求体 | 响应关键字段 |\n|------|------|------|--------|--------------|\n| 校验令牌 + 查用户/余额 | GET | `/api/user/self` | — | `data.id`、`data.username`、`data.quota`(剩余额度)、`data.used_quota` |\n| 拉分组倍率 | GET | `/api/user/self/groups` | — | `data: { : { desc, ratio } }` |\n| 站点全部分组 | GET | `/api/group` | — | `data: string[]` |\n| 账号可用模型 | GET | `/api/user/models` | — | `data: string[]` |\n| 创建代理 key | POST | `/api/token/` | `CreateTokenRequest`(见下) | `data.key` / `success` |\n| key 列表 | GET | `/api/token/?p={p}&size={n}` | — | `data.items[]`(含 `id/key/name/status/remain_quota`) |\n| 更新 key | PUT | `/api/token/` | `{ ...token, id }` | `success` |\n| 删除 key | DELETE | `/api/token/{id}` | — | `success` |\n\n`CreateTokenRequest`:\n\n```json\n{\n \"name\": \"ccx-autopilot\",\n \"remain_quota\": 0,\n \"expired_time\": -1,\n \"unlimited_quota\": true,\n \"model_limits_enabled\": false,\n \"model_limits\": \"\",\n \"allow_ips\": \"\",\n \"group\": \"\"\n}\n```\n\n`quota` 是 new-api 内部额度单位(非美元),换算比例读 `GET /api/status` 的 `price` / `PaymentUSDRate`(无鉴权),或允许用户在订阅中手填换算率;MVP 直接以 quota 原值展示 + 站点换算率估算余额。\n\n**接入流程**(订阅中心「新增订阅 → 类型选 new-api」):\n\n```text\n1. 用户输入:站点 baseURL + 系统访问令牌(access_token) + userId\n (userId 可选自动获取:先用令牌调 /api/user/self 拿 data.id 回填)\n2. 校验:GET /api/user/self\n ├─ success=false / 401 → 令牌无效或 userId 不匹配,报错停止\n └─ 成功 → 记录 username、quota,创建 SubscriptionProfile(provider=new_api, originTier=second)\n3. 拉分组:GET /api/user/self/groups → 写入 GroupMultipliers({group: ratio})\n GET /api/user/models → 记录账号可用模型,供渠道 supportedModels 参考\n4. 建 key:POST /api/token/(按 ProvisionKeyName/Group/Models 模板)\n ├─ 已存在同名 ccx-autopilot key → 复用其 id,不重复创建(列表比对 name)\n └─ 新建成功 → 回填 ProvisionedTokenID,取 data.key 作为渠道 apiKey\n5. 自动建渠道:用 baseURL + 新建 key 创建一个上游渠道\n ├─ channelKind 由用户选择或按站点探测(messages/chat/responses/gemini)\n ├─ status=unknown,autoManaged=true,OriginType=relay\n ├─ 建立 SubscriptionUID → ChannelUID 链接(回填 LinkedChannelUID)\n └─ 触发 §8.4 的 Discovery + 能力测试标准流程\n6. 完成:订阅卡片显示余额、分组倍率、已绑定渠道、探测进度\n```\n\n关键点:new-api 集成**不额外造探测逻辑**,第 5 步之后完全复用现有 §8.4「添加并探测」链路;集成层只负责「令牌 → key + 分组 + 余额 + baseURL」的自动填充,把手填 baseURL/key 的动作自动化掉。\n\n**刷新与同步**(复用 `POST /api/subscriptions/{uid}/refresh`):\n\n| 触发 | 动作 |\n|------|------|\n| 手动刷新余额 | `GET /api/user/self` 更新 `Balance` + `BalanceUpdatedAt` |\n| 定时刷新(可选,opt-in) | 复用 Phase 4 Item 6 `SubscriptionAutoRefresh`;new-api 作为 `relay` provider adapter 纳入,默认关闭 |\n| 分组倍率变更 | 刷新时对比 `/api/user/self/groups`,`GroupMultipliers` 变化 → 更新并提示「套餐倍率已变」 |\n| key 失效检测 | 渠道侧探测到该 key 全 401 → 订阅卡片提示「key 可能已删除/禁用」,提供「重新建 key」一键动作(重跑第 4-5 步,复用旧渠道,只换 apiKey) |\n| 可用模型变更 | `/api/user/models` 结果哈希变化 → 触发对应渠道重新 Discovery |\n\n**安全与边界**:\n- `access_token` 权限等同 new-api 账户,须加密存储、脱敏展示、禁止写入日志;刷新失败连续 N 次自动停用该订阅的自动刷新,只保留手动。\n- 自动建 key 默认 `unlimited_quota=true` 但可由用户改为限额模板;建 key 是有副作用的写操作,须在接入前明确告知用户「将在 new-api 侧创建一个名为 ccx-autopilot 的令牌」。\n- new-api 是 `OriginTier=second`,**不得**用作隐私敏感的 routing advisor / classifier(遵守 §3.2.1 约束)。\n- fork 兼容:请求同时带 `New-API-User` 与 `User-id` 头;响应信封字段名(`success/data/message`)在部分 fork 可能不同,adapter 需容错解析,失败时回退手动录入。\n- 一个 new-api 订阅默认只自动建/绑定一个渠道;用户如需该站多协议渠道,可在同订阅下手动增建,共用同一 key。\n\n### 8.5.2 订阅 Provider Adapter 框架(多来源余额与接入)\n\n§8.5.1 的 new-api 集成不是特例,而是「订阅 Provider Adapter」框架的一个实现。Phase 4 Item 6 已落地 openai/anthropic/google 的余额刷新 adapter,本框架把它抽象成统一接口,让 new-api 及后续来源(GitHub Copilot、Codex、Nvidia、MiniMax、DeepSeek 等)按相同契约接入,避免每加一个来源就散落一套逻辑(DRY)。\n\n**统一接口**:\n\n```go\n// backend-go/internal/autopilot/subscription/adapter.go\ntype ProviderAdapter interface {\n Kind() string // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | \"copilot\" | \"deepseek\" | ...\n\n // 校验凭据,返回账号身份(用户名/账户标识);失败即凭据无效\n Verify(ctx context.Context, cred Credential) (AccountInfo, error)\n // 查余额/额度;不支持返回 ErrUnsupported,由 UI 降级为手动录入\n FetchBalance(ctx context.Context, cred Credential) (Balance, error)\n // 拉分组倍率;仅 new-api 类中转有,官方直连返回 ErrUnsupported\n FetchGroups(ctx context.Context, cred Credential) (map[string]GroupRatio, error)\n // 自动创建代理 key;官方直连通常不支持(key 即用户 API key),返回 ErrUnsupported\n ProvisionKey(ctx context.Context, cred Credential, tmpl KeyTemplate) (ProvisionedKey, error)\n // 可用模型列表;用于渠道 supportedModels 参考\n FetchModels(ctx context.Context, cred Credential) ([]string, error)\n}\n```\n\n每个能力独立、可选:adapter 只实现自己支持的部分,不支持的统一返回 `ErrUnsupported`(接口隔离原则),UI 据此隐藏/降级对应入口。所有 adapter 共用 `SubscriptionAutoRefresh` 的 opt-in 开关与 `Validate()` 兜底,与 Phase 4 现有防御模式一致。\n\n**能力矩阵**(✅ 支持 / ⚠️ 部分或需确认 / ❌ 不支持,走手动):\n\n| 来源 | Kind | 验证 | 查余额 | 拉分组 | 自动建 key | OriginTier |\n|------|------|:----:|:------:|:------:|:----------:|------------|\n| new-api family | `new_api` | ✅ | ✅ quota | ✅ | ✅ | second |\n| OpenAI 官方 | `openai` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Anthropic 官方 | `anthropic` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Google 官方 | `google` | ✅ | ⚠️ | ❌ | ❌ | first |\n| GitHub Copilot | `copilot` | ⚠️ token | ❌ 无额度概念 | ❌ | ❌ | first |\n| Codex plan | `codex` | ⚠️ | ⚠️ 用量窗口 | ❌ | ❌ | first |\n| Nvidia NIM | `nvidia` | ✅ | ⚠️ credits | ❌ | ❌ | first |\n| MiniMax | `minimax` | ✅ | ⚠️ | ❌ | ❌ | first |\n| DeepSeek | `deepseek` | ✅ | ✅ balance API | ❌ | ❌ | first |\n\n说明:官方直连来源普遍**不支持自动建 key**(用户的 API key 本身就是凭据,直接作为渠道 apiKey 录入即可)、也**没有分组倍率**(价格按官方定价表)。它们的 adapter 价值集中在「验证 + 查余额/用量窗口」,把 §3.2.4 UsageWindow 的官方用量 API 来源坐实。DeepSeek 有独立余额查询端点,可完整支持余额。Copilot 属订阅制、无 token 余额概念,只做验证 + 用量提示。\n\n**接入分层**(YAGNI:不强求一次做全,按价值排期):\n\n```text\nP0(本轮):new_api(完整:验证+余额+分组+建key+自动建渠道)\n —— 覆盖用户最主要的中转站场景\nP1:deepseek(验证+余额)、官方三家余额已在 Phase 4 完成 → 补 UsageWindow 对齐\nP2:nvidia / minimax(验证+credits/余额)\nP3:copilot / codex(验证+用量窗口提示,无余额)\n```\n\n每个来源的具体端点在实现时于 `adapter.go` 各 provider 文件内落地,本设计只固定接口契约与能力矩阵,避免文档与上游 API 变动强耦合。新增来源 = 实现 `ProviderAdapter` + 在能力矩阵登记,不改框架。\n\n**能力缺失与降级**:\n- adapter 任一能力返回 `ErrUnsupported` → 订阅中心对应字段回退为手动录入(余额手填、倍率手填),不报错、不阻塞接入。\n- adapter 验证失败/超时 → 订阅可仍以「手动模式」创建,仅失去自动刷新与自动建 key,其余流程(手填 baseURL+key 建渠道)不受影响。\n- 官方来源无「自动建 key」时,接入流程退化为 §8.4 标准快速添加:用户填 API key,adapter 只补验证与余额。\n- 所有自动刷新遵守 `SubscriptionAutoRefresh` 默认关闭;连续失败自动停用自动刷新并在驾驶舱「人工待办」提示。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3,\n \"providerTimePricing\": {\n \"deepseek\": {\n \"effectiveFrom\": \"2026-07-20T00:00:00+08:00\",\n \"timeZone\": \"Asia/Shanghai\",\n \"peakMultiplier\": 2,\n \"peakWindows\": [\n { \"start\": \"09:00\", \"end\": \"12:00\" },\n { \"start\": \"14:00\", \"end\": \"18:00\" }\n ]\n }\n }\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n`providerTimePricing.effectiveFrom` 使用 RFC3339;修改该值即可调整激活时间,设为空字符串则停用对应规则。\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**Phase 3A 状态(2026-07-09)**:低风险、不改真实路由语义的子集已完成并接入真实链路——`Manager.collectAll()` 新旧画像对比产出变更事件(`DetectProfileChanges`)、`ProfileChangelogStore`(环形内存 + SQLite,30 天机会性清理)、`EventHub` 内存 pub/sub、`GET /api/health-center/changelog`(历史)+ `GET /api/health-center/events`(WebSocket 实时推送)、`AutoDiscoveryRunner` 发布 `discovery_completed`/`auto_mapping_applied`、前端 `ProfileChangelogTimeline.vue` 接入健康中心页并支持断线自动重连。均为只读展示,未修改 `SmartRouter`/`EndpointAttemptPolicy` 调度逻辑。落地时顺带发现并修复一个真实缺口:浏览器原生 WebSocket 无法设自定义 header,为此给 `middleware.getAPIKey` 增加了 `Sec-WebSocket-Protocol` 鉴权回退(业界标准做法,仅新增一个 key 来源,不降低现有校验)。\n\n**Phase 3B(已全部完成,2026-07-09)**:模型自动映射、自动恢复探测、晋升/降级机制会直接改变真实调度决策,风险明显高于 3A,四个子项已分批全部完成。§12.2 P1.5 的 SQLite schema version / 版本化 migration / `OriginType`&`OriginTier` 旧配置 backfill / `killSwitch`+按 task class/channel disable **已于 2026-07-09 补强完成**(详见 §12.2 P1.5)。**Phase 3B-1(自动恢复探测补全)已于 2026-07-09 完成**:修复了 L2 探测子系统里此前从未打通的两个真实 bug——① `collectAll()` 每轮把新构造的画像整行 Upsert,会把 `ProbeWorker` 刚写入的 `LastProbeAt`/`ProbeSuccess`/`ConsecutiveProbeSuccess` 等字段清零,导致 6 小时探测冷却期形同虚设(修复:`carryForwardProbeFields` 在 Upsert 前搬运 Probe* 字段,不影响 L1 诊断字段的现有覆盖逻辑);② `extractRawAPIKey` 是返回 `KeyMask` 掩码值的占位 stub,探测请求实际带的是假 key(修复:新增 `APIKeyResolver` 回调 + `Manager.ResolveAPIKey`,通过遍历配置里对应渠道的 `APIKeys` 逐个计算 `KeyHashFromAPIKey` 比对来还原明文;resolver 未注入或未命中时 fail-open 跳过探测且不消耗预算)。同时补齐了 `degraded/limited → healthy` 的连续成功恢复路径(新增 `KeyEndpointProfile.ConsecutiveProbeSuccess` 计数器 + `HealthCheckConfig.ProbeRecoveryThreshold`,默认连续 2 次探测成功才恢复,避免单次探测噪声导致状态抖动;探测失败计数器清零)。**Phase 3B-3(StabilityTier 晋降级滞后)已于 2026-07-09 完成**(提交 664d3fff):在原本每轮从瞬时 1h 窗口重算的无记忆 `StabilityTier` 上加一层连续窗口滞后——新增 `KeyEndpointProfile.EffectiveStabilityTier`/`StabilityPendingTier`/`StabilityPendingStreak` 三字段 + 纯函数 `applyStabilityHysteresis`(对称阈值:连续 N 轮同值才切换生效档,`HealthCheckConfig.StabilityHysteresisWindows` 默认 2;首次画像直接采用,不等待阈值,避免新渠道卡在零值),`AggregateChannelProfile` 改读 `EffectiveStabilityTier`(空则回退 `StabilityTier`)。晋升/降级机制的数据模型仅限于 StabilityTier,不改变 QualityTier/SpeedTier/CostTier 语义。**Phase 3B-2(模型自动映射闭环)已于 2026-07-09 完成**(提交 0f02e988,随后修复一处 critical 集成 bug,提交 856372c4):新建 `ModelProfileStore`(SQLite,`model_profiles` 表)+ 扩展 `AutoDiscoveryRunner` 为每个发现模型写入 `ModelProfile`(仅 `AutoManaged` 渠道);新建 `ModelResolver` 实现 §5.4 五步逻辑(手动 `RedirectModel` 短路 → `GetModelProfiles` → `filterByCapabilityFloor` → `rankBySimilarity`);`scheduler` 包新增 `ModelSupportResolverFunc` hook(fail-open,未注入或返回不支持时严格回退到原 `ExplainModelSupport`);`endpoint_policy.go` 的 `resolveMappedModel`(此前是永不触发的死代码——`profile.ModelMapping` 从未被写入)现调用 `ModelResolver`;`handlers/common` 在 `RedirectModel` 之后应用 `MappedModel` 覆盖请求体,并按 `EchoMappedModel` 配置回显 `X-CCX-Mapped-Model`/`X-CCX-Original-Model`/`X-CCX-Mapping-Source` 响应头。三条件安全门控:`ModelMapping.AutoResolve==true`(**默认由死配置的 true 改为 false**)AND `RoutingMode ∈ {assist, auto}` AND 无 `KillSwitch`,三者任一不满足则所有新增代码路径行为与今天字节级一致;且仅 `AutoManaged` 渠道触发,手动配置渠道完全不受影响。对抗性复核发现并修复一个真实 critical bug:`scoreEndpointForKey` 用空 `channelUID` 计算 `EndpointUID` 并写入 `modelByUID`,而 `handlers` 层用真实 `channelUID` 查 `modelByUID`,两者哈希永不相等导致 `MappedModel` 在 key 级调度路径上完全查不到(提交 856372c4 修复:命中画像后改用 `profile.EndpointUID`,新增回归测试锁定该不变量)。至此 Phase 3B 四个子项(自动恢复探测、StabilityTier 滞后、模型自动映射、active filter 前置判定)全部完成;SLO regression 自动回滚仍是独立子系统,留待后续单独立项。\n\n**范围**:\n- [x] 运行时指标驱动画像实时更新(Phase 3A:`collectAll()` 每轮 diff + changelog + WebSocket 推送)\n- [x] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)—— Phase 3B-2(2026-07-09 完成,提交 0f02e988 + 856372c4)\n- [x] active model filter / context filter 支持自动映射前置判定 —— Phase 3B-2:`scheduler.ModelSupportResolverFunc` fail-open 接入 `select.go`\n- [x] 自动恢复探测(limited/dead → healthy)—— Phase 3B-1(2026-07-09 完成):`APIKeyResolver` + `carryForwardProbeFields` + `ConsecutiveProbeSuccess`/`ProbeRecoveryThreshold`\n- [x] 晋升/降级机制(连续成功→升级,连续失败→降级)—— Phase 3B-3(2026-07-09 完成,提交 664d3fff):`StabilityTier` 连续窗口滞后(`EffectiveStabilityTier`),暂不含 QualityTier/SpeedTier/CostTier\n- [x] WebSocket 推送画像变更事件(Phase 3A:`GET /api/health-center/events`)\n- [x] 前端画像变更历史/时间线(Phase 3A:`ProfileChangelogTimeline.vue`)\n\n**预估工期**:2-3 周(3A、3B-1、3B-2、3B-3 均已完成;SLO regression 自动回滚待单独立项评估工期)\n\n### Phase 4:高级特性\n\n**Phase 4 状态(2026-07-09,全部完成)**:8 项全部实现并接入真实链路,均遵循\"默认关闭 + 显式 opt-in\"安全守则。\n\n**范围**:\n- [x] 多维度标签系统扩展(用户自定义标签)——Item 1\n- [x] 成本报表:按用户/模型/渠道/key 统计真实有效成本——Item 2(新增 `proxyKeyMask` 身份贯穿链路:`middleware.GetProxyKeyMask` → `ChannelLog`/`RequestRecord` → SQLite `proxy_key_mask` 列,供 Item 4 复用)\n- [x] SLO regression 自动回滚——Item 3(默认关闭;`SLORollbackConfig`,连续 degrading 窗口数默认 3,不做自动恢复以避免震荡)\n- [x] 渠道推荐:根据使用模式推荐新渠道——Item 4\n- [x] 批量渠道管理(导入/导出/模板)——Item 5\n- [x] 订阅中心 provider adapter:可选自动刷新余额/套餐状态——Item 6(默认关闭;仅 openai/anthropic/google 且显式填写 BillingAPIKey 的订阅生效,中转/公益渠道继续手动维护)\n- [x] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板——Item 7\n- [x] A/B 测试:低比例统计抽样双发——Item 8(默认关闭,默认 1% 抽样 + 每小时 60 次硬预算;主请求路径不受影响,影子请求在主响应返回后异步发起)\n\n**关键实现说明**:\n- Item 3/6/8 均在 `AutopilotRoutingConfig` 新增独立配置块(`SLORollback`/`SubscriptionAutoRefresh`/`ABTest`),`Validate()` 统一做负值/越界兜底,保持与既有字段一致的防御模式。\n- Item 4 直接复用 Item 2 的 `proxyKeyMask` 身份标记,未重新发明身份识别机制。\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- ~~SQLite schema version 和幂等 migration。~~ **已完成**:`ensureSchemaVersion` 基于 `PRAGMA user_version`,当前 7 张表定版为 v1 基线;库版本高于代码版本时返回 error(fail-closed,不做任何写操作),交给下游 fail-open 分支接管。\n- ~~旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。~~ **已完成**:`ensureOriginBackfill` 只补 `\"unknown\"`,不做任何基于 URL/名称的猜测推断;已接线到 `Profiler.DeriveEndpointProfile`,`BreakTieByOriginTier` 现在能吃到真实数据(此前一直是 fallback 的 `unknown`)。\n- ~~profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。~~ **已确认满足并测试锁定**:`main.go` 现有结构本就是 fail-open(`NewProfileStore`/`NewManager` 出错只打日志、`autopilotManager` 保持 `nil`,下游全部有 `nil` 守卫),本次新增 `schema_migration_test.go` 用测试锁定这条不变量。\n- ~~全局 `killSwitch`、按 task class disable、按 channel disable。~~ **已完成**:新增 `AutopilotRoutingConfig.DisabledTaskClasses`/`DisabledChannelUIDs`;`CandidateFilterFor`/`executeFilter`/`BuildPlan` 三处一致接入,`smart_router_invariant_test.go` 覆盖\"task class 禁用后回退默认调度\"\"channel 禁用后不出现在候选里\"\"dry-run 与真实路径行为一致\"三条不变量。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。**待实现**:`TrustedRoutingAdvisor.promotionMode` 目前是 `manual`,shadow→candidate→active 已有人工审查关卡;自动回滚是独立的新子系统(需要基线捕获窗口 + 实时对比 + 阈值判定),留待下一轮单独设计落地。\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | shadow 样本/准确率/critical misroute 门槛;active 后 SLO regression 自动 rollback;全局 kill switch |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "df7b48e5940c32135b1655810e94cf8fe861f2cc": {"t": 1785759356.8251345, "n": 157662, "h": "ec10bb3a3dfe1fc2", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.2.3 同订阅多 Key:能力共享与配额负载均衡\n\n**问题**:官方订阅 plan(Claude Max、Codex plan 等)允许用户持有多个 key,或购买多份相同套餐。若每个 endpoint 独立探测与配置,探测预算被浪费、配置重复;请求量大时还需要在同 plan 的多个 key 之间按官方 RPM 限速自动负载均衡,而不是打满第一个 key 才溢出到下一个。\n\n**能力检测去重(同一套餐只需一套能力检测和配置)**:\n\n```text\nSubscriptionUID 相同的 endpoint 共享一份「订阅级能力画像」:\n 模型列表 / vision / tool / reasoning 支持 / 协议兼容开关 → 只探测一次,\n 存在 SubscriptionProfile.SharedCapability,endpoint 画像以引用方式继承。\n\n仍然 per-endpoint 维护的:健康状态、延迟、熔断、用量窗口\n (key 可能被单独封禁/限速,能力相同不代表运行态相同)\n\n继承失效条件:某 key 实测模型列表哈希与订阅级不一致(GroupChangeDetector 信号)\n → 该 key 脱离继承、独立探测,并在 UI 标记「与套餐能力不一致」警告。\n```\n\n**配额负载均衡(Phase 1 shadow 展示,Phase 2 生效)**:\n\n```text\n每 key 维护配额余量视图:\n remainingRPM = officialRPM − 当前窗口已用\n officialRPM 来源优先级:内置 manifest > 用户配置 > RateLimitDiscoverer 建议\n\n同订阅多 key 时按「剩余余量加权」分配请求(余量大者优先、\n窗口最早重置者兜底),替代顺序打满:\n 仅当渠道 autoManaged 且同一 SubscriptionUID 下 key 数 > 1 时启用;\n 否则保持现有 sequential/random key 顺序策略不变。\n\n某 key 的窗口配额耗尽 → 临时降权并切到同订阅其他 key,\n不熔断整个渠道(这是配额耗尽,不是故障)。\n```\n\n### 3.2.4 订阅用量窗口 (Usage Windows)\n\n部分官方 plan 提供 5h / 日 / 周 / 月等窗口的用量查询与限制(如 Claude 订阅的 5h 滚动窗口 + 周配额)。在 key 的位置展示这些窗口,帮助用户掌握使用情况:\n\n```go\ntype UsageWindow struct {\n Window string `json:\"window\"` // \"5h\" | \"day\" | \"week\" | \"month\"\n Used float64 `json:\"used\"` // 已用量\n Limit float64 `json:\"limit\"` // 上限,0 = 未知\n Unit string `json:\"unit\"` // requests | tokens | credits | percent\n ResetsAt time.Time `json:\"resetsAt\"` // 窗口重置时间\n Source string `json:\"source\"` // official_api | response_header | local_metering\n FetchedAt time.Time `json:\"fetchedAt\"`\n}\n// KeyEndpointProfile 与 SubscriptionProfile 均增加 UsageWindows []UsageWindow\n```\n\n数据来源优先级:\n\n```text\n1. 官方用量 API:有 provider adapter 的 plan 主动拉取(§Phase 4 订阅 adapter 的可提前子集)\n2. 响应头:anthropic-ratelimit-* / x-ratelimit-* 等(复用 RateLimitDiscoverer 的信号 tap)\n3. 本地计量兜底:CCX 按 key 累计请求数/token(误差来自窗口起点对齐,标注 Source=local_metering)\n```\n\nUI 要求:key 行内显示主窗口(5h)用量进度条,悬浮展开全部窗口;用量 >80% 黄色预警、超限红色并显示预计重置时间。调度联动(Phase 2):窗口余量进入配额负载均衡权重,窗口耗尽的 key 临时降权而非熔断。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n#### 4.2.1 内置模型清单 (Builtin Models Manifest)\n\n**问题**:部分官方订阅 plan 的入口没有正确的 models 接口——要么不存在(404)、要么需要额外权限、要么返回与订阅实际可用模型不符的列表(典型:Claude 订阅 OAuth 入口、Codex plan 入口、部分官方企业网关)。对这些 baseURL 做模型发现会失败或产出错误画像。\n\n**方案**:CCX 内置「已知官方 baseURL → 模型清单」映射表,命中时直接返回内置内容,完全跳过上游 models 接口:\n\n```go\n// backend-go/internal/config/builtin_models_manifest.go(生成式,随模型注册表更新)\ntype BuiltinModelsManifest struct {\n BaseURLPattern string `json:\"baseUrlPattern\"` // 精确 host 或前缀匹配,如 \"api.anthropic.com\"\n ServiceType string `json:\"serviceType\"` // messages/responses/…\n PlanHint string `json:\"planHint,omitempty\"` // 订阅类型提示(如 \"claude_subscription\")\n ModelIDs []string `json:\"modelIds\"` // 该入口实际可用的模型清单\n DisableProbe bool `json:\"disableProbe\"` // true 时 Discovery 跳过 GET /v1/models\n}\n```\n\n**两个生效位置**:\n\n```text\n1. Discovery(§4.2 流程第 2 步之前):\n baseURL 命中 manifest 且 DisableProbe=true\n → 跳过 GET /v1/models,直接用 ModelIDs 生成 ModelProfile[]\n → ProfileSource = \"builtin_manifest\",不再因 models 接口失败而误判 misconfigured\n\n2. /v1/models 代理响应(现有 Models API):\n 客户端向 CCX 请求 /v1/models 时,聚合结果中该渠道的贡献\n 直接取内置清单,不向上游发起 models 请求\n (渠道显式配置了 supportedModels 时仍以用户配置优先)\n```\n\n**维护与覆盖顺序**:\n\n```text\n渠道显式 supportedModels(用户手动) > 内置 manifest > 上游 models 接口实探 > 内置候选模型回退\n```\n\nmanifest 与模型注册表同源生成(`generated_model_registry.go` 同级),新模型发布时随注册表一起更新;HealthAnalyzer 对命中 manifest 的 endpoint 不将「models 接口 404/403」计入 misconfigured 信号。\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法细分时进入 `general`;有规范 Knowledge 基准则使用其软评分,缺少直接证据时仍取 0.5 中性值。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n**手动 L3 第一版状态(2026-07-15)**:已实现 `pq-v1-20260715` 固定 JSON canary,覆盖\nMessages、Chat Completions、Responses 与 Gemini 上游协议。调用方只能指定 `endpointUid`、\n`modelId` 和 1~3 次重复数,不能注入 prompt。评分仍按 40% 完整性、30% 语义正确性、\n15% 严格 JSON 格式、15% 延迟执行;第一版使用非流式完整响应延迟作为首 token 延迟代理,\n后续引入流式采样时再替换该维度。单次有效样本 confidence=0.6,最多三次提升到 0.8;\n失败样本按 0 分计入均值,全部失败不写画像。\n\n安全边界:仅管理 API 手动触发,不启动自动 worker;当前进程按 UTC 日设置 12 次硬预算,\n请求前原子预留,单次 90 秒超时、响应体上限 64 KiB。同一 endpoint×model 不并发执行;\n单次请求最多允许 2048 个输出 token,以容纳推理模型的隐藏思考并限制最坏额度消耗。\n对声明可控推理的模型固定采用最低可用 effort;Claude 兼容端点若只暴露\n`reasoning_content` 而未声明 thinking 模式,则与 capability-test 一致显式关闭 thinking。\n原始模型输出和明文 Key 既不落盘也不返回,只保存分数、canary 版本、置信度和脱敏判定。\n`user_feedback` 仍高于 `probe`,自动发现重建 ModelProfile 时必须保留既有质量证据。\n跨进程共享预算与 LLM-as-judge/语义向量评分仍属于后续阶段。\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设 + 自定义\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n| `custom` | 用户自定义 | 用户自定义 | 高级用户微调,范围 0.0 ~ 3.0 |\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法细分的通用任务;缺少基准证据时中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general;有规范 Knowledge 基准时使用其软评分,缺少直接证据时取 0.5 中性。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? canonicalBenchmark[model][domain] * providerQualityFactor\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 规范模型基准 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n#### 5.7.4 规范模型能力基准\n\n`shared/model-registry/ccx_model_registry.json` 的 `benchmarkProfiles` 独立保存规范模型能力上界,不与 `upstreamCapabilities` 混合。后者仍是 context、vision、tool calls 等硬能力的事实源;benchmark 只参与 Autopilot 软评分和 trace 解释,绝不改变 `supportedModels` 或 CapabilityFloor。\n\n```text\nEffectiveCapability[domain]\n = CanonicalCeiling[model, domain]\n × ProviderQualityFactor[endpoint]\n\nProviderQualityFactor = 1 - confidence × (1 - providerQualityScore)\n无渠道质量证据或 confidence<0.5 时 factor=1;可信证据只能从规范模型上界向下折算。\n```\n\n基准数据必须保留领域向量、来源、核验日期、lane、共享结果数和可比类别覆盖率,不能只保存站点 Overall。当前 BenchLM 接入为 `provisional`:Claude Opus 4.8、GPT-5.6 Terra、GPT-5.6 Sol 各有 30 个共享结果,8 类中 5 类可比,核验日期为 2026-07-13。\n\n领域映射:Knowledge → `general`,Math → `reasoning`,Coding → `coding` / `code_review`,Agentic → `agentic`,Multimodal → `aesthetics_ui`(仅弱代理,解释置信度减半)。Writing / Translation 没有直接类别证据时继续回退家族种子或 0.5。BenchLM 的 cohort 归一化、缺失项插补和外部校准决定了 Overall 不是绝对能力值,因此路由只消费可映射的原始类别分。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**请求意图边界(ModelRoutingPolicy)**\n\n跨模型替代不是所有模型的默认能力,只对白名单客户端入口开放:\n\n```text\nmessages:\n fable / opus / sonnet / haiku 及对应 claude-* 完整模型名 → adaptive\nresponses:\n gpt-5.6* / gpt-5.5 / gpt-5.4* / codex-auto-review → adaptive\n其他协议或模型:\n exact-only,只能在探测到相同规范化模型 ID 的 endpoint 之间调度\n```\n\n`adaptive` 允许在满足 Capability Floor 后选择 GLM、MiMo 等其他模型族;`exact-only` 禁止跨模型替代。用户显式配置的 `modelMapping` 仍优先于该策略。调度预览和 endpoint 实际解析必须调用同一分类函数,避免诊断结果与真实请求分叉。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选最佳匹配\n // 匹配优先级:\n // a. 同模型族(opus→opus, sonnet→sonnet)—— 最高优先\n // b. 同质量档(premium→premium)\n // c. 上下文窗口最接近(不超也不差太多)\n // d. 探测延迟最低\n best := rankBySimilarity(eligible, requestModel, floor)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - autoManaged 的空 supportedModels 不表示支持全部,必须由 endpoint ModelProfile 裁决\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n当 ModelResolver 已正常初始化且路由处于 `assist/auto + autoResolve=true` 时,autoManaged 渠道的画像拒绝是权威结果,调度器不得再 fail-open 到空 `supportedModels` 的兼容语义。Resolver 不可用、开关关闭或 `off/shadow` 时仍保留原有 fail-open,保证急停和降级路径不依赖画像库。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\nGET /api/health-center/provider-quality/budget → 查看本进程手动 L3 每日预算\nPOST /api/health-center/provider-quality/probe → 对指定 endpoint×model 执行固定质量 canary\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n#### 8.0.1 实现现状与演进方向(2026-07-09 核实)\n\n四界面在 **Web 前端(`frontend/src`)已大面积落地**,不是纯文档:\n\n| 界面 | Web 实现 | Desktop 实现 |\n|------|----------|--------------|\n| 健康中心 | ✅ `HealthCenterView.vue` + `HealthCenterStats`/`HealthChannelTable`/`HealthChannelDetail`/`ProfileChangelogTimeline`,路由 `/health` | ❌ 无 |\n| 订阅中心 | ✅ `SubscriptionsView.vue` + `SubscriptionPlanTable`,CRUD + 刷新余额,路由 `/subscriptions` | ⚠️ 独立旧实现 `subscriptions/SubscriptionTab.vue`(与 Web 两套代码) |\n| 渠道中心 | ✅ `ChannelOrchestration.vue`(六态 `ChannelHealthBadge`、来源 chips、`autoManaged` 图标齐备) | ❌ 未同步 |\n| 管理面板 | ⚠️ `AutopilotModePanel.vue` 仅暴露 mode/killSwitch/costPreference | ❌ 无 |\n| 驾驶舱 | ✅ `CockpitView.vue`(观测型总览:健康统计+余额汇总+渠道推荐) | ⚠️ `cockpit` tab 渲染的是会话 Dashboard,职责不同 |\n\n**与本设计意图的关系**(对齐用户「自动调度为主、废旧手动驾驶舱」的诉求):\n- **旧的「手动调度驾驶舱」在 Web 端已不存在**。`CockpitView` 已是 §8.7 定义的观测型总览,只回答健康/省钱/风险/待办四问题,不做渠道配置编辑,符合「程序自动选、人只观测+有限干预」的方向。无需再废除,只需继续收敛为纯观测。\n- **统一提交入口已存在**(§8.4 快速添加 + §8.5 订阅中心),new-api 集成(§8.5.1)在此之上把「填 baseURL+key」进一步自动化。\n\n**前端演进待办**(本设计新增/明确):\n1. **补 `ManualRoutingIntent` 前端**:这是用户要的「有限偏向性干预」,后端已有、两个前端均未实现(§8.3 试用意图入口尚缺)。\n2. **Desktop 前端对齐 Web**:健康中心/渠道自动托管/管理面板/观测型驾驶舱在 Desktop 几乎空白,需同步或复用 Web 组件,避免长期维护两套。\n3. **管理面板补字段**:暴露 `l2ProbeEnabled`、探测预算、new-api/多来源订阅 adapter 开关(§8.5.2)等,当前只有 3 个字段。\n4. **订阅中心接 §8.5.1/§8.5.2**:新增「new-api」订阅类型与 Provider Adapter 能力矩阵驱动的动态表单。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.5.1 new-api 订阅集成(统一账号接入)\n\n**目标**:用户在订阅中心手填一个 new-api(及其 fork)账号的「系统访问令牌」即可,CCX 自动用该令牌创建代理专用 key、拉取分组倍率、查询额度余额,并把「站点 baseURL + 新建 key」自动落成一个上游渠道,交给 Autopilot 探测与调度。\n\n**范围约束**:\n- 只使用 new-api 用户在「个人设置」生成的 **系统访问令牌(access_token)** 一种凭据;令牌用于 API 调用的身份验证,属敏感数据,须妥善加密存储。\n- **不做** cookie 登录 + 2FA + 导出后台渠道明文 key 的管理员迁移路径(all-api-hub 的 newApiSession 能力),本设计不涉及。\n- new-api 归入 `OriginType=relay`、`OriginTier=second`(中转站),信任等级不因自动化提升;实时质量仍由运行画像决定。\n- 认证令牌像其他订阅凭据一样由用户手动录入,CCX 不代替用户在 new-api 侧注册或登录。\n\n**认证与凭据模型**:\n\nnew-api family 的管理接口鉴权(依据 all-api-hub 传输层实现):\n\n```text\nAuthorization: Bearer # 系统访问令牌;raw 模式可去掉 Bearer 前缀\nNew-API-User: # 当前用户 ID(必带)\nUser-id: # fork 兼容回退(Veloera/voapi/Super-API 等)\nContent-Type: application/json\n```\n\n统一响应信封:`{ \"success\": bool, \"data\": T, \"message\": string }`;解析时先判 `success`,再取 `data`。\n\n**SubscriptionProfile 扩展**:\n\n在 §3.2.2 的 `SubscriptionProfile` 基础上,为可自动接入的订阅新增 provider 集成字段(沿用「默认关闭 + 显式 opt-in」守则):\n\n```go\n// SubscriptionProfile 新增字段(复用 Phase 4 Item 6 的 SubscriptionAutoRefresh 开关)\ntype SubscriptionIntegration struct {\n Kind string `json:\"kind\"` // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | ...\n BaseURL string `json:\"baseUrl\"` // new-api 站点地址(同时作为新建渠道的上游 baseURL)\n AccessToken string `json:\"-\"` // 系统访问令牌;加密存储,不出 API 响应\n UserID string `json:\"userId\"` // New-API-User 头所需\n AuthTokenMode string `json:\"authTokenMode,omitempty\"` // \"bearer\"(默认) | \"raw\"\n // 自动建 key 的模板\n ProvisionKeyName string `json:\"provisionKeyName,omitempty\"` // 默认 \"ccx-autopilot\"\n ProvisionGroup string `json:\"provisionGroup,omitempty\"` // 建 key 时指定分组,空=默认分组\n ProvisionModels []string `json:\"provisionModels,omitempty\"` // model_limits,空=不限制\n // 自动同步产物(回填,只读展示)\n ProvisionedTokenID int `json:\"provisionedTokenId,omitempty\"`\n LinkedChannelUID string `json:\"linkedChannelUid,omitempty\"`\n}\n```\n\n`AccessToken` 必须与现有敏感字段一样走加密存储,序列化到前端时脱敏(仅显示尾部若干位)。\n\n**new-api 接口清单**(本集成实际调用的子集,路径以主线 new-api 为准,字段名保持与上游一致):\n\n| 用途 | 方法 | 路径 | 请求体 | 响应关键字段 |\n|------|------|------|--------|--------------|\n| 校验令牌 + 查用户/余额 | GET | `/api/user/self` | — | `data.id`、`data.username`、`data.quota`(剩余额度)、`data.used_quota` |\n| 拉分组倍率 | GET | `/api/user/self/groups` | — | `data: { : { desc, ratio } }` |\n| 站点全部分组 | GET | `/api/group` | — | `data: string[]` |\n| 账号可用模型 | GET | `/api/user/models` | — | `data: string[]` |\n| 创建代理 key | POST | `/api/token/` | `CreateTokenRequest`(见下) | `data.key` / `success` |\n| key 列表 | GET | `/api/token/?p={p}&size={n}` | — | `data.items[]`(含 `id/key/name/status/remain_quota`) |\n| 更新 key | PUT | `/api/token/` | `{ ...token, id }` | `success` |\n| 删除 key | DELETE | `/api/token/{id}` | — | `success` |\n\n`CreateTokenRequest`:\n\n```json\n{\n \"name\": \"ccx-autopilot\",\n \"remain_quota\": 0,\n \"expired_time\": -1,\n \"unlimited_quota\": true,\n \"model_limits_enabled\": false,\n \"model_limits\": \"\",\n \"allow_ips\": \"\",\n \"group\": \"\"\n}\n```\n\n`quota` 是 new-api 内部额度单位(非美元),换算比例读 `GET /api/status` 的 `price` / `PaymentUSDRate`(无鉴权),或允许用户在订阅中手填换算率;MVP 直接以 quota 原值展示 + 站点换算率估算余额。\n\n**接入流程**(订阅中心「新增订阅 → 类型选 new-api」):\n\n```text\n1. 用户输入:站点 baseURL + 系统访问令牌(access_token) + userId\n (userId 可选自动获取:先用令牌调 /api/user/self 拿 data.id 回填)\n2. 校验:GET /api/user/self\n ├─ success=false / 401 → 令牌无效或 userId 不匹配,报错停止\n └─ 成功 → 记录 username、quota,创建 SubscriptionProfile(provider=new_api, originTier=second)\n3. 拉分组:GET /api/user/self/groups → 写入 GroupMultipliers({group: ratio})\n GET /api/user/models → 记录账号可用模型,供渠道 supportedModels 参考\n4. 建 key:POST /api/token/(按 ProvisionKeyName/Group/Models 模板)\n ├─ 已存在同名 ccx-autopilot key → 复用其 id,不重复创建(列表比对 name)\n └─ 新建成功 → 回填 ProvisionedTokenID,取 data.key 作为渠道 apiKey\n5. 自动建渠道:用 baseURL + 新建 key 创建一个上游渠道\n ├─ channelKind 由用户选择或按站点探测(messages/chat/responses/gemini)\n ├─ status=unknown,autoManaged=true,OriginType=relay\n ├─ 建立 SubscriptionUID → ChannelUID 链接(回填 LinkedChannelUID)\n └─ 触发 §8.4 的 Discovery + 能力测试标准流程\n6. 完成:订阅卡片显示余额、分组倍率、已绑定渠道、探测进度\n```\n\n关键点:new-api 集成**不额外造探测逻辑**,第 5 步之后完全复用现有 §8.4「添加并探测」链路;集成层只负责「令牌 → key + 分组 + 余额 + baseURL」的自动填充,把手填 baseURL/key 的动作自动化掉。\n\n**刷新与同步**(复用 `POST /api/subscriptions/{uid}/refresh`):\n\n| 触发 | 动作 |\n|------|------|\n| 手动刷新余额 | `GET /api/user/self` 更新 `Balance` + `BalanceUpdatedAt` |\n| 定时刷新(可选,opt-in) | 复用 Phase 4 Item 6 `SubscriptionAutoRefresh`;new-api 作为 `relay` provider adapter 纳入,默认关闭 |\n| 分组倍率变更 | 刷新时对比 `/api/user/self/groups`,`GroupMultipliers` 变化 → 更新并提示「套餐倍率已变」 |\n| key 失效检测 | 渠道侧探测到该 key 全 401 → 订阅卡片提示「key 可能已删除/禁用」,提供「重新建 key」一键动作(重跑第 4-5 步,复用旧渠道,只换 apiKey) |\n| 可用模型变更 | `/api/user/models` 结果哈希变化 → 触发对应渠道重新 Discovery |\n\n**安全与边界**:\n- `access_token` 权限等同 new-api 账户,须加密存储、脱敏展示、禁止写入日志;刷新失败连续 N 次自动停用该订阅的自动刷新,只保留手动。\n- 自动建 key 默认 `unlimited_quota=true` 但可由用户改为限额模板;建 key 是有副作用的写操作,须在接入前明确告知用户「将在 new-api 侧创建一个名为 ccx-autopilot 的令牌」。\n- new-api 是 `OriginTier=second`,**不得**用作隐私敏感的 routing advisor / classifier(遵守 §3.2.1 约束)。\n- fork 兼容:请求同时带 `New-API-User` 与 `User-id` 头;响应信封字段名(`success/data/message`)在部分 fork 可能不同,adapter 需容错解析,失败时回退手动录入。\n- 一个 new-api 订阅默认只自动建/绑定一个渠道;用户如需该站多协议渠道,可在同订阅下手动增建,共用同一 key。\n\n### 8.5.2 订阅 Provider Adapter 框架(多来源余额与接入)\n\n§8.5.1 的 new-api 集成不是特例,而是「订阅 Provider Adapter」框架的一个实现。Phase 4 Item 6 已落地 openai/anthropic/google 的余额刷新 adapter,本框架把它抽象成统一接口,让 new-api 及后续来源(GitHub Copilot、Codex、Nvidia、MiniMax、DeepSeek 等)按相同契约接入,避免每加一个来源就散落一套逻辑(DRY)。\n\n**统一接口**:\n\n```go\n// backend-go/internal/autopilot/subscription/adapter.go\ntype ProviderAdapter interface {\n Kind() string // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | \"copilot\" | \"deepseek\" | ...\n\n // 校验凭据,返回账号身份(用户名/账户标识);失败即凭据无效\n Verify(ctx context.Context, cred Credential) (AccountInfo, error)\n // 查余额/额度;不支持返回 ErrUnsupported,由 UI 降级为手动录入\n FetchBalance(ctx context.Context, cred Credential) (Balance, error)\n // 拉分组倍率;仅 new-api 类中转有,官方直连返回 ErrUnsupported\n FetchGroups(ctx context.Context, cred Credential) (map[string]GroupRatio, error)\n // 自动创建代理 key;官方直连通常不支持(key 即用户 API key),返回 ErrUnsupported\n ProvisionKey(ctx context.Context, cred Credential, tmpl KeyTemplate) (ProvisionedKey, error)\n // 可用模型列表;用于渠道 supportedModels 参考\n FetchModels(ctx context.Context, cred Credential) ([]string, error)\n}\n```\n\n每个能力独立、可选:adapter 只实现自己支持的部分,不支持的统一返回 `ErrUnsupported`(接口隔离原则),UI 据此隐藏/降级对应入口。所有 adapter 共用 `SubscriptionAutoRefresh` 的 opt-in 开关与 `Validate()` 兜底,与 Phase 4 现有防御模式一致。\n\n**能力矩阵**(✅ 支持 / ⚠️ 部分或需确认 / ❌ 不支持,走手动):\n\n| 来源 | Kind | 验证 | 查余额 | 拉分组 | 自动建 key | OriginTier |\n|------|------|:----:|:------:|:------:|:----------:|------------|\n| new-api family | `new_api` | ✅ | ✅ quota | ✅ | ✅ | second |\n| OpenAI 官方 | `openai` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Anthropic 官方 | `anthropic` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Google 官方 | `google` | ✅ | ⚠️ | ❌ | ❌ | first |\n| GitHub Copilot | `copilot` | ⚠️ token | ❌ 无额度概念 | ❌ | ❌ | first |\n| Codex plan | `codex` | ⚠️ | ⚠️ 用量窗口 | ❌ | ❌ | first |\n| Nvidia NIM | `nvidia` | ✅ | ⚠️ credits | ❌ | ❌ | first |\n| MiniMax | `minimax` | ✅ | ⚠️ | ❌ | ❌ | first |\n| DeepSeek | `deepseek` | ✅ | ✅ balance API | ❌ | ❌ | first |\n\n说明:官方直连来源普遍**不支持自动建 key**(用户的 API key 本身就是凭据,直接作为渠道 apiKey 录入即可)、也**没有分组倍率**(价格按官方定价表)。它们的 adapter 价值集中在「验证 + 查余额/用量窗口」,把 §3.2.4 UsageWindow 的官方用量 API 来源坐实。DeepSeek 有独立余额查询端点,可完整支持余额。Copilot 属订阅制、无 token 余额概念,只做验证 + 用量提示。\n\n**接入分层**(YAGNI:不强求一次做全,按价值排期):\n\n```text\nP0(本轮):new_api(完整:验证+余额+分组+建key+自动建渠道)\n —— 覆盖用户最主要的中转站场景\nP1:deepseek(验证+余额)、官方三家余额已在 Phase 4 完成 → 补 UsageWindow 对齐\nP2:nvidia / minimax(验证+credits/余额)\nP3:copilot / codex(验证+用量窗口提示,无余额)\n```\n\n每个来源的具体端点在实现时于 `adapter.go` 各 provider 文件内落地,本设计只固定接口契约与能力矩阵,避免文档与上游 API 变动强耦合。新增来源 = 实现 `ProviderAdapter` + 在能力矩阵登记,不改框架。\n\n**能力缺失与降级**:\n- adapter 任一能力返回 `ErrUnsupported` → 订阅中心对应字段回退为手动录入(余额手填、倍率手填),不报错、不阻塞接入。\n- adapter 验证失败/超时 → 订阅可仍以「手动模式」创建,仅失去自动刷新与自动建 key,其余流程(手填 baseURL+key 建渠道)不受影响。\n- 官方来源无「自动建 key」时,接入流程退化为 §8.4 标准快速添加:用户填 API key,adapter 只补验证与余额。\n- 所有自动刷新遵守 `SubscriptionAutoRefresh` 默认关闭;连续失败自动停用自动刷新并在驾驶舱「人工待办」提示。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3,\n \"providerTimePricing\": {\n \"deepseek\": {\n \"effectiveFrom\": \"2026-07-20T00:00:00+08:00\",\n \"timeZone\": \"Asia/Shanghai\",\n \"peakMultiplier\": 2,\n \"peakWindows\": [\n { \"start\": \"09:00\", \"end\": \"12:00\" },\n { \"start\": \"14:00\", \"end\": \"18:00\" }\n ]\n }\n }\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n`providerTimePricing.effectiveFrom` 使用 RFC3339;修改该值即可调整激活时间,设为空字符串则停用对应规则。\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**Phase 3A 状态(2026-07-09)**:低风险、不改真实路由语义的子集已完成并接入真实链路——`Manager.collectAll()` 新旧画像对比产出变更事件(`DetectProfileChanges`)、`ProfileChangelogStore`(环形内存 + SQLite,30 天机会性清理)、`EventHub` 内存 pub/sub、`GET /api/health-center/changelog`(历史)+ `GET /api/health-center/events`(WebSocket 实时推送)、`AutoDiscoveryRunner` 发布 `discovery_completed`/`auto_mapping_applied`、前端 `ProfileChangelogTimeline.vue` 接入健康中心页并支持断线自动重连。均为只读展示,未修改 `SmartRouter`/`EndpointAttemptPolicy` 调度逻辑。落地时顺带发现并修复一个真实缺口:浏览器原生 WebSocket 无法设自定义 header,为此给 `middleware.getAPIKey` 增加了 `Sec-WebSocket-Protocol` 鉴权回退(业界标准做法,仅新增一个 key 来源,不降低现有校验)。\n\n**Phase 3B(已全部完成,2026-07-09)**:模型自动映射、自动恢复探测、晋升/降级机制会直接改变真实调度决策,风险明显高于 3A,四个子项已分批全部完成。§12.2 P1.5 的 SQLite schema version / 版本化 migration / `OriginType`&`OriginTier` 旧配置 backfill / `killSwitch`+按 task class/channel disable **已于 2026-07-09 补强完成**(详见 §12.2 P1.5)。**Phase 3B-1(自动恢复探测补全)已于 2026-07-09 完成**:修复了 L2 探测子系统里此前从未打通的两个真实 bug——① `collectAll()` 每轮把新构造的画像整行 Upsert,会把 `ProbeWorker` 刚写入的 `LastProbeAt`/`ProbeSuccess`/`ConsecutiveProbeSuccess` 等字段清零,导致 6 小时探测冷却期形同虚设(修复:`carryForwardProbeFields` 在 Upsert 前搬运 Probe* 字段,不影响 L1 诊断字段的现有覆盖逻辑);② `extractRawAPIKey` 是返回 `KeyMask` 掩码值的占位 stub,探测请求实际带的是假 key(修复:新增 `APIKeyResolver` 回调 + `Manager.ResolveAPIKey`,通过遍历配置里对应渠道的 `APIKeys` 逐个计算 `KeyHashFromAPIKey` 比对来还原明文;resolver 未注入或未命中时 fail-open 跳过探测且不消耗预算)。同时补齐了 `degraded/limited → healthy` 的连续成功恢复路径(新增 `KeyEndpointProfile.ConsecutiveProbeSuccess` 计数器 + `HealthCheckConfig.ProbeRecoveryThreshold`,默认连续 2 次探测成功才恢复,避免单次探测噪声导致状态抖动;探测失败计数器清零)。**Phase 3B-3(StabilityTier 晋降级滞后)已于 2026-07-09 完成**(提交 664d3fff):在原本每轮从瞬时 1h 窗口重算的无记忆 `StabilityTier` 上加一层连续窗口滞后——新增 `KeyEndpointProfile.EffectiveStabilityTier`/`StabilityPendingTier`/`StabilityPendingStreak` 三字段 + 纯函数 `applyStabilityHysteresis`(对称阈值:连续 N 轮同值才切换生效档,`HealthCheckConfig.StabilityHysteresisWindows` 默认 2;首次画像直接采用,不等待阈值,避免新渠道卡在零值),`AggregateChannelProfile` 改读 `EffectiveStabilityTier`(空则回退 `StabilityTier`)。晋升/降级机制的数据模型仅限于 StabilityTier,不改变 QualityTier/SpeedTier/CostTier 语义。**Phase 3B-2(模型自动映射闭环)已于 2026-07-09 完成**(提交 0f02e988,随后修复一处 critical 集成 bug,提交 856372c4):新建 `ModelProfileStore`(SQLite,`model_profiles` 表)+ 扩展 `AutoDiscoveryRunner` 为每个发现模型写入 `ModelProfile`(仅 `AutoManaged` 渠道);新建 `ModelResolver` 实现 §5.4 五步逻辑(手动 `RedirectModel` 短路 → `GetModelProfiles` → `filterByCapabilityFloor` → `rankBySimilarity`);`scheduler` 包新增 `ModelSupportResolverFunc` hook(fail-open,未注入或返回不支持时严格回退到原 `ExplainModelSupport`);`endpoint_policy.go` 的 `resolveMappedModel`(此前是永不触发的死代码——`profile.ModelMapping` 从未被写入)现调用 `ModelResolver`;`handlers/common` 在 `RedirectModel` 之后应用 `MappedModel` 覆盖请求体,并按 `EchoMappedModel` 配置回显 `X-CCX-Mapped-Model`/`X-CCX-Original-Model`/`X-CCX-Mapping-Source` 响应头。三条件安全门控:`ModelMapping.AutoResolve==true`(**默认由死配置的 true 改为 false**)AND `RoutingMode ∈ {assist, auto}` AND 无 `KillSwitch`,三者任一不满足则所有新增代码路径行为与今天字节级一致;且仅 `AutoManaged` 渠道触发,手动配置渠道完全不受影响。对抗性复核发现并修复一个真实 critical bug:`scoreEndpointForKey` 用空 `channelUID` 计算 `EndpointUID` 并写入 `modelByUID`,而 `handlers` 层用真实 `channelUID` 查 `modelByUID`,两者哈希永不相等导致 `MappedModel` 在 key 级调度路径上完全查不到(提交 856372c4 修复:命中画像后改用 `profile.EndpointUID`,新增回归测试锁定该不变量)。至此 Phase 3B 四个子项(自动恢复探测、StabilityTier 滞后、模型自动映射、active filter 前置判定)全部完成;SLO regression 自动回滚仍是独立子系统,留待后续单独立项。\n\n**范围**:\n- [x] 运行时指标驱动画像实时更新(Phase 3A:`collectAll()` 每轮 diff + changelog + WebSocket 推送)\n- [x] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)—— Phase 3B-2(2026-07-09 完成,提交 0f02e988 + 856372c4)\n- [x] active model filter / context filter 支持自动映射前置判定 —— Phase 3B-2:`scheduler.ModelSupportResolverFunc` fail-open 接入 `select.go`\n- [x] 自动恢复探测(limited/dead → healthy)—— Phase 3B-1(2026-07-09 完成):`APIKeyResolver` + `carryForwardProbeFields` + `ConsecutiveProbeSuccess`/`ProbeRecoveryThreshold`\n- [x] 晋升/降级机制(连续成功→升级,连续失败→降级)—— Phase 3B-3(2026-07-09 完成,提交 664d3fff):`StabilityTier` 连续窗口滞后(`EffectiveStabilityTier`),暂不含 QualityTier/SpeedTier/CostTier\n- [x] WebSocket 推送画像变更事件(Phase 3A:`GET /api/health-center/events`)\n- [x] 前端画像变更历史/时间线(Phase 3A:`ProfileChangelogTimeline.vue`)\n\n**预估工期**:2-3 周(3A、3B-1、3B-2、3B-3 均已完成;SLO regression 自动回滚待单独立项评估工期)\n\n### Phase 4:高级特性\n\n**Phase 4 状态(2026-07-09,全部完成)**:8 项全部实现并接入真实链路,均遵循\"默认关闭 + 显式 opt-in\"安全守则。\n\n**范围**:\n- [x] 多维度标签系统扩展(用户自定义标签)——Item 1\n- [x] 成本报表:按用户/模型/渠道/key 统计真实有效成本——Item 2(新增 `proxyKeyMask` 身份贯穿链路:`middleware.GetProxyKeyMask` → `ChannelLog`/`RequestRecord` → SQLite `proxy_key_mask` 列,供 Item 4 复用)\n- [x] SLO regression 自动回滚——Item 3(默认关闭;`SLORollbackConfig`,连续 degrading 窗口数默认 3,不做自动恢复以避免震荡)\n- [x] 渠道推荐:根据使用模式推荐新渠道——Item 4\n- [x] 批量渠道管理(导入/导出/模板)——Item 5\n- [x] 订阅中心 provider adapter:可选自动刷新余额/套餐状态——Item 6(默认关闭;仅 openai/anthropic/google 且显式填写 BillingAPIKey 的订阅生效,中转/公益渠道继续手动维护)\n- [x] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板——Item 7\n- [x] A/B 测试:低比例统计抽样双发——Item 8(默认关闭,默认 1% 抽样 + 每小时 60 次硬预算;主请求路径不受影响,影子请求在主响应返回后异步发起)\n\n**关键实现说明**:\n- Item 3/6/8 均在 `AutopilotRoutingConfig` 新增独立配置块(`SLORollback`/`SubscriptionAutoRefresh`/`ABTest`),`Validate()` 统一做负值/越界兜底,保持与既有字段一致的防御模式。\n- Item 4 直接复用 Item 2 的 `proxyKeyMask` 身份标记,未重新发明身份识别机制。\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- ~~SQLite schema version 和幂等 migration。~~ **已完成**:`ensureSchemaVersion` 基于 `PRAGMA user_version`,当前 7 张表定版为 v1 基线;库版本高于代码版本时返回 error(fail-closed,不做任何写操作),交给下游 fail-open 分支接管。\n- ~~旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。~~ **已完成**:`ensureOriginBackfill` 只补 `\"unknown\"`,不做任何基于 URL/名称的猜测推断;已接线到 `Profiler.DeriveEndpointProfile`,`BreakTieByOriginTier` 现在能吃到真实数据(此前一直是 fallback 的 `unknown`)。\n- ~~profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。~~ **已确认满足并测试锁定**:`main.go` 现有结构本就是 fail-open(`NewProfileStore`/`NewManager` 出错只打日志、`autopilotManager` 保持 `nil`,下游全部有 `nil` 守卫),本次新增 `schema_migration_test.go` 用测试锁定这条不变量。\n- ~~全局 `killSwitch`、按 task class disable、按 channel disable。~~ **已完成**:新增 `AutopilotRoutingConfig.DisabledTaskClasses`/`DisabledChannelUIDs`;`CandidateFilterFor`/`executeFilter`/`BuildPlan` 三处一致接入,`smart_router_invariant_test.go` 覆盖\"task class 禁用后回退默认调度\"\"channel 禁用后不出现在候选里\"\"dry-run 与真实路径行为一致\"三条不变量。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。**待实现**:`TrustedRoutingAdvisor.promotionMode` 目前是 `manual`,shadow→candidate→active 已有人工审查关卡;自动回滚是独立的新子系统(需要基线捕获窗口 + 实时对比 + 阈值判定),留待下一轮单独设计落地。\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | shadow 样本/准确率/critical misroute 门槛;active 后 SLO regression 自动 rollback;全局 kill switch |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "7c2810d913d669beaf425dfbaea80bfa29fda6f6": {"t": 1785759360.1118343, "n": 157390, "h": "57b2cfd64758f831", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.2.3 同订阅多 Key:能力共享与配额负载均衡\n\n**问题**:官方订阅 plan(Claude Max、Codex plan 等)允许用户持有多个 key,或购买多份相同套餐。若每个 endpoint 独立探测与配置,探测预算被浪费、配置重复;请求量大时还需要在同 plan 的多个 key 之间按官方 RPM 限速自动负载均衡,而不是打满第一个 key 才溢出到下一个。\n\n**能力检测去重(同一套餐只需一套能力检测和配置)**:\n\n```text\nSubscriptionUID 相同的 endpoint 共享一份「订阅级能力画像」:\n 模型列表 / vision / tool / reasoning 支持 / 协议兼容开关 → 只探测一次,\n 存在 SubscriptionProfile.SharedCapability,endpoint 画像以引用方式继承。\n\n仍然 per-endpoint 维护的:健康状态、延迟、熔断、用量窗口\n (key 可能被单独封禁/限速,能力相同不代表运行态相同)\n\n继承失效条件:某 key 实测模型列表哈希与订阅级不一致(GroupChangeDetector 信号)\n → 该 key 脱离继承、独立探测,并在 UI 标记「与套餐能力不一致」警告。\n```\n\n**配额负载均衡(Phase 1 shadow 展示,Phase 2 生效)**:\n\n```text\n每 key 维护配额余量视图:\n remainingRPM = officialRPM − 当前窗口已用\n officialRPM 来源优先级:内置 manifest > 用户配置 > RateLimitDiscoverer 建议\n\n同订阅多 key 时按「剩余余量加权」分配请求(余量大者优先、\n窗口最早重置者兜底),替代顺序打满:\n 仅当渠道 autoManaged 且同一 SubscriptionUID 下 key 数 > 1 时启用;\n 否则保持现有 sequential/random key 顺序策略不变。\n\n某 key 的窗口配额耗尽 → 临时降权并切到同订阅其他 key,\n不熔断整个渠道(这是配额耗尽,不是故障)。\n```\n\n### 3.2.4 订阅用量窗口 (Usage Windows)\n\n部分官方 plan 提供 5h / 日 / 周 / 月等窗口的用量查询与限制(如 Claude 订阅的 5h 滚动窗口 + 周配额)。在 key 的位置展示这些窗口,帮助用户掌握使用情况:\n\n```go\ntype UsageWindow struct {\n Window string `json:\"window\"` // \"5h\" | \"day\" | \"week\" | \"month\"\n Used float64 `json:\"used\"` // 已用量\n Limit float64 `json:\"limit\"` // 上限,0 = 未知\n Unit string `json:\"unit\"` // requests | tokens | credits | percent\n ResetsAt time.Time `json:\"resetsAt\"` // 窗口重置时间\n Source string `json:\"source\"` // official_api | response_header | local_metering\n FetchedAt time.Time `json:\"fetchedAt\"`\n}\n// KeyEndpointProfile 与 SubscriptionProfile 均增加 UsageWindows []UsageWindow\n```\n\n数据来源优先级:\n\n```text\n1. 官方用量 API:有 provider adapter 的 plan 主动拉取(§Phase 4 订阅 adapter 的可提前子集)\n2. 响应头:anthropic-ratelimit-* / x-ratelimit-* 等(复用 RateLimitDiscoverer 的信号 tap)\n3. 本地计量兜底:CCX 按 key 累计请求数/token(误差来自窗口起点对齐,标注 Source=local_metering)\n```\n\nUI 要求:key 行内显示主窗口(5h)用量进度条,悬浮展开全部窗口;用量 >80% 黄色预警、超限红色并显示预计重置时间。调度联动(Phase 2):窗口余量进入配额负载均衡权重,窗口耗尽的 key 临时降权而非熔断。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n#### 4.2.1 内置模型清单 (Builtin Models Manifest)\n\n**问题**:部分官方订阅 plan 的入口没有正确的 models 接口——要么不存在(404)、要么需要额外权限、要么返回与订阅实际可用模型不符的列表(典型:Claude 订阅 OAuth 入口、Codex plan 入口、部分官方企业网关)。对这些 baseURL 做模型发现会失败或产出错误画像。\n\n**方案**:CCX 内置「已知官方 baseURL → 模型清单」映射表,命中时直接返回内置内容,完全跳过上游 models 接口:\n\n```go\n// backend-go/internal/config/builtin_models_manifest.go(生成式,随模型注册表更新)\ntype BuiltinModelsManifest struct {\n BaseURLPattern string `json:\"baseUrlPattern\"` // 精确 host 或前缀匹配,如 \"api.anthropic.com\"\n ServiceType string `json:\"serviceType\"` // messages/responses/…\n PlanHint string `json:\"planHint,omitempty\"` // 订阅类型提示(如 \"claude_subscription\")\n ModelIDs []string `json:\"modelIds\"` // 该入口实际可用的模型清单\n DisableProbe bool `json:\"disableProbe\"` // true 时 Discovery 跳过 GET /v1/models\n}\n```\n\n**两个生效位置**:\n\n```text\n1. Discovery(§4.2 流程第 2 步之前):\n baseURL 命中 manifest 且 DisableProbe=true\n → 跳过 GET /v1/models,直接用 ModelIDs 生成 ModelProfile[]\n → ProfileSource = \"builtin_manifest\",不再因 models 接口失败而误判 misconfigured\n\n2. /v1/models 代理响应(现有 Models API):\n 客户端向 CCX 请求 /v1/models 时,聚合结果中该渠道的贡献\n 直接取内置清单,不向上游发起 models 请求\n (渠道显式配置了 supportedModels 时仍以用户配置优先)\n```\n\n**维护与覆盖顺序**:\n\n```text\n渠道显式 supportedModels(用户手动) > 内置 manifest > 上游 models 接口实探 > 内置候选模型回退\n```\n\nmanifest 与模型注册表同源生成(`generated_model_registry.go` 同级),新模型发布时随注册表一起更新;HealthAnalyzer 对命中 manifest 的 endpoint 不将「models 接口 404/403」计入 misconfigured 信号。\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法细分时进入 `general`;有规范 Knowledge 基准则使用其软评分,缺少直接证据时仍取 0.5 中性值。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n**手动 L3 第一版状态(2026-07-15)**:已实现 `pq-v1-20260715` 固定 JSON canary,覆盖\nMessages、Chat Completions、Responses 与 Gemini 上游协议。调用方只能指定 `endpointUid`、\n`modelId` 和 1~3 次重复数,不能注入 prompt。评分仍按 40% 完整性、30% 语义正确性、\n15% 严格 JSON 格式、15% 延迟执行;第一版使用非流式完整响应延迟作为首 token 延迟代理,\n后续引入流式采样时再替换该维度。单次有效样本 confidence=0.6,最多三次提升到 0.8;\n失败样本按 0 分计入均值,全部失败不写画像。\n\n安全边界:仅管理 API 手动触发,不启动自动 worker;当前进程按 UTC 日设置 12 次硬预算,\n请求前原子预留,单次 90 秒超时、响应体上限 64 KiB。同一 endpoint×model 不并发执行;\n单次请求最多允许 2048 个输出 token,以容纳推理模型的隐藏思考并限制最坏额度消耗。\n对声明可控推理的模型固定采用最低可用 effort;Claude 兼容端点若只暴露\n`reasoning_content` 而未声明 thinking 模式,则与 capability-test 一致显式关闭 thinking。\n原始模型输出和明文 Key 既不落盘也不返回,只保存分数、canary 版本、置信度和脱敏判定。\n`user_feedback` 仍高于 `probe`,自动发现重建 ModelProfile 时必须保留既有质量证据。\n跨进程共享预算与 LLM-as-judge/语义向量评分仍属于后续阶段。\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设 + 自定义\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n| `custom` | 用户自定义 | 用户自定义 | 高级用户微调,范围 0.0 ~ 3.0 |\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法细分的通用任务;缺少基准证据时中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general;有规范 Knowledge 基准时使用其软评分,缺少直接证据时取 0.5 中性。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? canonicalBenchmark[model][domain] * providerQualityFactor\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 规范模型基准 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n#### 5.7.4 规范模型能力基准\n\n`shared/model-registry/ccx_model_registry.json` 的 `benchmarkProfiles` 独立保存规范模型能力上界,不与 `upstreamCapabilities` 混合。后者仍是 context、vision、tool calls 等硬能力的事实源;benchmark 只参与 Autopilot 软评分和 trace 解释,绝不改变 `supportedModels` 或 CapabilityFloor。\n\n```text\nEffectiveCapability[domain]\n = CanonicalCeiling[model, domain]\n × ProviderQualityFactor[endpoint]\n\nProviderQualityFactor = 1 - confidence × (1 - providerQualityScore)\n无渠道质量证据或 confidence<0.5 时 factor=1;可信证据只能从规范模型上界向下折算。\n```\n\n基准数据必须保留领域向量、来源、核验日期、lane、共享结果数和可比类别覆盖率,不能只保存站点 Overall。当前 BenchLM 接入为 `provisional`:Claude Opus 4.8、GPT-5.6 Terra、GPT-5.6 Sol 各有 30 个共享结果,8 类中 5 类可比,核验日期为 2026-07-13。\n\n领域映射:Knowledge → `general`,Math → `reasoning`,Coding → `coding` / `code_review`,Agentic → `agentic`,Multimodal → `aesthetics_ui`(仅弱代理,解释置信度减半)。Writing / Translation 没有直接类别证据时继续回退家族种子或 0.5。BenchLM 的 cohort 归一化、缺失项插补和外部校准决定了 Overall 不是绝对能力值,因此路由只消费可映射的原始类别分。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**请求意图边界(ModelRoutingPolicy)**\n\n跨模型替代不是所有模型的默认能力,只对白名单客户端入口开放:\n\n```text\nmessages:\n fable / opus / sonnet / haiku 及对应 claude-* 完整模型名 → adaptive\nresponses:\n gpt-5.6* / gpt-5.5 / gpt-5.4* / codex-auto-review → adaptive\n其他协议或模型:\n exact-only,只能在探测到相同规范化模型 ID 的 endpoint 之间调度\n```\n\n`adaptive` 允许在满足 Capability Floor 后选择 GLM、MiMo 等其他模型族;`exact-only` 禁止跨模型替代。用户显式配置的 `modelMapping` 仍优先于该策略。调度预览和 endpoint 实际解析必须调用同一分类函数,避免诊断结果与真实请求分叉。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选最佳匹配\n // 匹配优先级:\n // a. 同模型族(opus→opus, sonnet→sonnet)—— 最高优先\n // b. 同质量档(premium→premium)\n // c. 上下文窗口最接近(不超也不差太多)\n // d. 探测延迟最低\n best := rankBySimilarity(eligible, requestModel, floor)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\nGET /api/health-center/provider-quality/budget → 查看本进程手动 L3 每日预算\nPOST /api/health-center/provider-quality/probe → 对指定 endpoint×model 执行固定质量 canary\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n#### 8.0.1 实现现状与演进方向(2026-07-09 核实)\n\n四界面在 **Web 前端(`frontend/src`)已大面积落地**,不是纯文档:\n\n| 界面 | Web 实现 | Desktop 实现 |\n|------|----------|--------------|\n| 健康中心 | ✅ `HealthCenterView.vue` + `HealthCenterStats`/`HealthChannelTable`/`HealthChannelDetail`/`ProfileChangelogTimeline`,路由 `/health` | ❌ 无 |\n| 订阅中心 | ✅ `SubscriptionsView.vue` + `SubscriptionPlanTable`,CRUD + 刷新余额,路由 `/subscriptions` | ⚠️ 独立旧实现 `subscriptions/SubscriptionTab.vue`(与 Web 两套代码) |\n| 渠道中心 | ✅ `ChannelOrchestration.vue`(六态 `ChannelHealthBadge`、来源 chips、`autoManaged` 图标齐备) | ❌ 未同步 |\n| 管理面板 | ⚠️ `AutopilotModePanel.vue` 仅暴露 mode/killSwitch/costPreference | ❌ 无 |\n| 驾驶舱 | ✅ `CockpitView.vue`(观测型总览:健康统计+余额汇总+渠道推荐) | ⚠️ `cockpit` tab 渲染的是会话 Dashboard,职责不同 |\n\n**与本设计意图的关系**(对齐用户「自动调度为主、废旧手动驾驶舱」的诉求):\n- **旧的「手动调度驾驶舱」在 Web 端已不存在**。`CockpitView` 已是 §8.7 定义的观测型总览,只回答健康/省钱/风险/待办四问题,不做渠道配置编辑,符合「程序自动选、人只观测+有限干预」的方向。无需再废除,只需继续收敛为纯观测。\n- **统一提交入口已存在**(§8.4 快速添加 + §8.5 订阅中心),new-api 集成(§8.5.1)在此之上把「填 baseURL+key」进一步自动化。\n\n**前端演进待办**(本设计新增/明确):\n1. **补 `ManualRoutingIntent` 前端**:这是用户要的「有限偏向性干预」,后端已有、两个前端均未实现(§8.3 试用意图入口尚缺)。\n2. **Desktop 前端对齐 Web**:健康中心/渠道自动托管/管理面板/观测型驾驶舱在 Desktop 几乎空白,需同步或复用 Web 组件,避免长期维护两套。\n3. **管理面板补字段**:暴露 `l2ProbeEnabled`、探测预算、new-api/多来源订阅 adapter 开关(§8.5.2)等,当前只有 3 个字段。\n4. **订阅中心接 §8.5.1/§8.5.2**:新增「new-api」订阅类型与 Provider Adapter 能力矩阵驱动的动态表单。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.5.1 new-api 订阅集成(统一账号接入)\n\n**目标**:用户在订阅中心手填一个 new-api(及其 fork)账号的「系统访问令牌」即可,CCX 自动用该令牌创建代理专用 key、拉取分组倍率、查询额度余额,并把「站点 baseURL + 新建 key」自动落成一个上游渠道,交给 Autopilot 探测与调度。\n\n**范围约束**:\n- 只使用 new-api 用户在「个人设置」生成的 **系统访问令牌(access_token)** 一种凭据;令牌用于 API 调用的身份验证,属敏感数据,须妥善加密存储。\n- **不做** cookie 登录 + 2FA + 导出后台渠道明文 key 的管理员迁移路径(all-api-hub 的 newApiSession 能力),本设计不涉及。\n- new-api 归入 `OriginType=relay`、`OriginTier=second`(中转站),信任等级不因自动化提升;实时质量仍由运行画像决定。\n- 认证令牌像其他订阅凭据一样由用户手动录入,CCX 不代替用户在 new-api 侧注册或登录。\n\n**认证与凭据模型**:\n\nnew-api family 的管理接口鉴权(依据 all-api-hub 传输层实现):\n\n```text\nAuthorization: Bearer # 系统访问令牌;raw 模式可去掉 Bearer 前缀\nNew-API-User: # 当前用户 ID(必带)\nUser-id: # fork 兼容回退(Veloera/voapi/Super-API 等)\nContent-Type: application/json\n```\n\n统一响应信封:`{ \"success\": bool, \"data\": T, \"message\": string }`;解析时先判 `success`,再取 `data`。\n\n**SubscriptionProfile 扩展**:\n\n在 §3.2.2 的 `SubscriptionProfile` 基础上,为可自动接入的订阅新增 provider 集成字段(沿用「默认关闭 + 显式 opt-in」守则):\n\n```go\n// SubscriptionProfile 新增字段(复用 Phase 4 Item 6 的 SubscriptionAutoRefresh 开关)\ntype SubscriptionIntegration struct {\n Kind string `json:\"kind\"` // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | ...\n BaseURL string `json:\"baseUrl\"` // new-api 站点地址(同时作为新建渠道的上游 baseURL)\n AccessToken string `json:\"-\"` // 系统访问令牌;加密存储,不出 API 响应\n UserID string `json:\"userId\"` // New-API-User 头所需\n AuthTokenMode string `json:\"authTokenMode,omitempty\"` // \"bearer\"(默认) | \"raw\"\n // 自动建 key 的模板\n ProvisionKeyName string `json:\"provisionKeyName,omitempty\"` // 默认 \"ccx-autopilot\"\n ProvisionGroup string `json:\"provisionGroup,omitempty\"` // 建 key 时指定分组,空=默认分组\n ProvisionModels []string `json:\"provisionModels,omitempty\"` // model_limits,空=不限制\n // 自动同步产物(回填,只读展示)\n ProvisionedTokenID int `json:\"provisionedTokenId,omitempty\"`\n LinkedChannelUID string `json:\"linkedChannelUid,omitempty\"`\n}\n```\n\n`AccessToken` 必须与现有敏感字段一样走加密存储,序列化到前端时脱敏(仅显示尾部若干位)。\n\n**new-api 接口清单**(本集成实际调用的子集,路径以主线 new-api 为准,字段名保持与上游一致):\n\n| 用途 | 方法 | 路径 | 请求体 | 响应关键字段 |\n|------|------|------|--------|--------------|\n| 校验令牌 + 查用户/余额 | GET | `/api/user/self` | — | `data.id`、`data.username`、`data.quota`(剩余额度)、`data.used_quota` |\n| 拉分组倍率 | GET | `/api/user/self/groups` | — | `data: { : { desc, ratio } }` |\n| 站点全部分组 | GET | `/api/group` | — | `data: string[]` |\n| 账号可用模型 | GET | `/api/user/models` | — | `data: string[]` |\n| 创建代理 key | POST | `/api/token/` | `CreateTokenRequest`(见下) | `data.key` / `success` |\n| key 列表 | GET | `/api/token/?p={p}&size={n}` | — | `data.items[]`(含 `id/key/name/status/remain_quota`) |\n| 更新 key | PUT | `/api/token/` | `{ ...token, id }` | `success` |\n| 删除 key | DELETE | `/api/token/{id}` | — | `success` |\n\n`CreateTokenRequest`:\n\n```json\n{\n \"name\": \"ccx-autopilot\",\n \"remain_quota\": 0,\n \"expired_time\": -1,\n \"unlimited_quota\": true,\n \"model_limits_enabled\": false,\n \"model_limits\": \"\",\n \"allow_ips\": \"\",\n \"group\": \"\"\n}\n```\n\n`quota` 是 new-api 内部额度单位(非美元),换算比例读 `GET /api/status` 的 `price` / `PaymentUSDRate`(无鉴权),或允许用户在订阅中手填换算率;MVP 直接以 quota 原值展示 + 站点换算率估算余额。\n\n**接入流程**(订阅中心「新增订阅 → 类型选 new-api」):\n\n```text\n1. 用户输入:站点 baseURL + 系统访问令牌(access_token) + userId\n (userId 可选自动获取:先用令牌调 /api/user/self 拿 data.id 回填)\n2. 校验:GET /api/user/self\n ├─ success=false / 401 → 令牌无效或 userId 不匹配,报错停止\n └─ 成功 → 记录 username、quota,创建 SubscriptionProfile(provider=new_api, originTier=second)\n3. 拉分组:GET /api/user/self/groups → 写入 GroupMultipliers({group: ratio})\n GET /api/user/models → 记录账号可用模型,供渠道 supportedModels 参考\n4. 建 key:POST /api/token/(按 ProvisionKeyName/Group/Models 模板)\n ├─ 已存在同名 ccx-autopilot key → 复用其 id,不重复创建(列表比对 name)\n └─ 新建成功 → 回填 ProvisionedTokenID,取 data.key 作为渠道 apiKey\n5. 自动建渠道:用 baseURL + 新建 key 创建一个上游渠道\n ├─ channelKind 由用户选择或按站点探测(messages/chat/responses/gemini)\n ├─ status=unknown,autoManaged=true,OriginType=relay\n ├─ 建立 SubscriptionUID → ChannelUID 链接(回填 LinkedChannelUID)\n └─ 触发 §8.4 的 Discovery + 能力测试标准流程\n6. 完成:订阅卡片显示余额、分组倍率、已绑定渠道、探测进度\n```\n\n关键点:new-api 集成**不额外造探测逻辑**,第 5 步之后完全复用现有 §8.4「添加并探测」链路;集成层只负责「令牌 → key + 分组 + 余额 + baseURL」的自动填充,把手填 baseURL/key 的动作自动化掉。\n\n**刷新与同步**(复用 `POST /api/subscriptions/{uid}/refresh`):\n\n| 触发 | 动作 |\n|------|------|\n| 手动刷新余额 | `GET /api/user/self` 更新 `Balance` + `BalanceUpdatedAt` |\n| 定时刷新(可选,opt-in) | 复用 Phase 4 Item 6 `SubscriptionAutoRefresh`;new-api 作为 `relay` provider adapter 纳入,默认关闭 |\n| 分组倍率变更 | 刷新时对比 `/api/user/self/groups`,`GroupMultipliers` 变化 → 更新并提示「套餐倍率已变」 |\n| key 失效检测 | 渠道侧探测到该 key 全 401 → 订阅卡片提示「key 可能已删除/禁用」,提供「重新建 key」一键动作(重跑第 4-5 步,复用旧渠道,只换 apiKey) |\n| 可用模型变更 | `/api/user/models` 结果哈希变化 → 触发对应渠道重新 Discovery |\n\n**安全与边界**:\n- `access_token` 权限等同 new-api 账户,须加密存储、脱敏展示、禁止写入日志;刷新失败连续 N 次自动停用该订阅的自动刷新,只保留手动。\n- 自动建 key 默认 `unlimited_quota=true` 但可由用户改为限额模板;建 key 是有副作用的写操作,须在接入前明确告知用户「将在 new-api 侧创建一个名为 ccx-autopilot 的令牌」。\n- new-api 是 `OriginTier=second`,**不得**用作隐私敏感的 routing advisor / classifier(遵守 §3.2.1 约束)。\n- fork 兼容:请求同时带 `New-API-User` 与 `User-id` 头;响应信封字段名(`success/data/message`)在部分 fork 可能不同,adapter 需容错解析,失败时回退手动录入。\n- 一个 new-api 订阅默认只自动建/绑定一个渠道;用户如需该站多协议渠道,可在同订阅下手动增建,共用同一 key。\n\n### 8.5.2 订阅 Provider Adapter 框架(多来源余额与接入)\n\n§8.5.1 的 new-api 集成不是特例,而是「订阅 Provider Adapter」框架的一个实现。Phase 4 Item 6 已落地 openai/anthropic/google 的余额刷新 adapter,本框架把它抽象成统一接口,让 new-api 及后续来源(GitHub Copilot、Codex、Nvidia、MiniMax、DeepSeek 等)按相同契约接入,避免每加一个来源就散落一套逻辑(DRY)。\n\n**统一接口**:\n\n```go\n// backend-go/internal/autopilot/subscription/adapter.go\ntype ProviderAdapter interface {\n Kind() string // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | \"copilot\" | \"deepseek\" | ...\n\n // 校验凭据,返回账号身份(用户名/账户标识);失败即凭据无效\n Verify(ctx context.Context, cred Credential) (AccountInfo, error)\n // 查余额/额度;不支持返回 ErrUnsupported,由 UI 降级为手动录入\n FetchBalance(ctx context.Context, cred Credential) (Balance, error)\n // 拉分组倍率;仅 new-api 类中转有,官方直连返回 ErrUnsupported\n FetchGroups(ctx context.Context, cred Credential) (map[string]GroupRatio, error)\n // 自动创建代理 key;官方直连通常不支持(key 即用户 API key),返回 ErrUnsupported\n ProvisionKey(ctx context.Context, cred Credential, tmpl KeyTemplate) (ProvisionedKey, error)\n // 可用模型列表;用于渠道 supportedModels 参考\n FetchModels(ctx context.Context, cred Credential) ([]string, error)\n}\n```\n\n每个能力独立、可选:adapter 只实现自己支持的部分,不支持的统一返回 `ErrUnsupported`(接口隔离原则),UI 据此隐藏/降级对应入口。所有 adapter 共用 `SubscriptionAutoRefresh` 的 opt-in 开关与 `Validate()` 兜底,与 Phase 4 现有防御模式一致。\n\n**能力矩阵**(✅ 支持 / ⚠️ 部分或需确认 / ❌ 不支持,走手动):\n\n| 来源 | Kind | 验证 | 查余额 | 拉分组 | 自动建 key | OriginTier |\n|------|------|:----:|:------:|:------:|:----------:|------------|\n| new-api family | `new_api` | ✅ | ✅ quota | ✅ | ✅ | second |\n| OpenAI 官方 | `openai` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Anthropic 官方 | `anthropic` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Google 官方 | `google` | ✅ | ⚠️ | ❌ | ❌ | first |\n| GitHub Copilot | `copilot` | ⚠️ token | ❌ 无额度概念 | ❌ | ❌ | first |\n| Codex plan | `codex` | ⚠️ | ⚠️ 用量窗口 | ❌ | ❌ | first |\n| Nvidia NIM | `nvidia` | ✅ | ⚠️ credits | ❌ | ❌ | first |\n| MiniMax | `minimax` | ✅ | ⚠️ | ❌ | ❌ | first |\n| DeepSeek | `deepseek` | ✅ | ✅ balance API | ❌ | ❌ | first |\n\n说明:官方直连来源普遍**不支持自动建 key**(用户的 API key 本身就是凭据,直接作为渠道 apiKey 录入即可)、也**没有分组倍率**(价格按官方定价表)。它们的 adapter 价值集中在「验证 + 查余额/用量窗口」,把 §3.2.4 UsageWindow 的官方用量 API 来源坐实。DeepSeek 有独立余额查询端点,可完整支持余额。Copilot 属订阅制、无 token 余额概念,只做验证 + 用量提示。\n\n**接入分层**(YAGNI:不强求一次做全,按价值排期):\n\n```text\nP0(本轮):new_api(完整:验证+余额+分组+建key+自动建渠道)\n —— 覆盖用户最主要的中转站场景\nP1:deepseek(验证+余额)、官方三家余额已在 Phase 4 完成 → 补 UsageWindow 对齐\nP2:nvidia / minimax(验证+credits/余额)\nP3:copilot / codex(验证+用量窗口提示,无余额)\n```\n\n每个来源的具体端点在实现时于 `adapter.go` 各 provider 文件内落地,本设计只固定接口契约与能力矩阵,避免文档与上游 API 变动强耦合。新增来源 = 实现 `ProviderAdapter` + 在能力矩阵登记,不改框架。\n\n**能力缺失与降级**:\n- adapter 任一能力返回 `ErrUnsupported` → 订阅中心对应字段回退为手动录入(余额手填、倍率手填),不报错、不阻塞接入。\n- adapter 验证失败/超时 → 订阅可仍以「手动模式」创建,仅失去自动刷新与自动建 key,其余流程(手填 baseURL+key 建渠道)不受影响。\n- 官方来源无「自动建 key」时,接入流程退化为 §8.4 标准快速添加:用户填 API key,adapter 只补验证与余额。\n- 所有自动刷新遵守 `SubscriptionAutoRefresh` 默认关闭;连续失败自动停用自动刷新并在驾驶舱「人工待办」提示。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3,\n \"providerTimePricing\": {\n \"deepseek\": {\n \"effectiveFrom\": \"2026-07-20T00:00:00+08:00\",\n \"timeZone\": \"Asia/Shanghai\",\n \"peakMultiplier\": 2,\n \"peakWindows\": [\n { \"start\": \"09:00\", \"end\": \"12:00\" },\n { \"start\": \"14:00\", \"end\": \"18:00\" }\n ]\n }\n }\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n`providerTimePricing.effectiveFrom` 使用 RFC3339;修改该值即可调整激活时间,设为空字符串则停用对应规则。\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**Phase 3A 状态(2026-07-09)**:低风险、不改真实路由语义的子集已完成并接入真实链路——`Manager.collectAll()` 新旧画像对比产出变更事件(`DetectProfileChanges`)、`ProfileChangelogStore`(环形内存 + SQLite,30 天机会性清理)、`EventHub` 内存 pub/sub、`GET /api/health-center/changelog`(历史)+ `GET /api/health-center/events`(WebSocket 实时推送)、`AutoDiscoveryRunner` 发布 `discovery_completed`/`auto_mapping_applied`、前端 `ProfileChangelogTimeline.vue` 接入健康中心页并支持断线自动重连。均为只读展示,未修改 `SmartRouter`/`EndpointAttemptPolicy` 调度逻辑。落地时顺带发现并修复一个真实缺口:浏览器原生 WebSocket 无法设自定义 header,为此给 `middleware.getAPIKey` 增加了 `Sec-WebSocket-Protocol` 鉴权回退(业界标准做法,仅新增一个 key 来源,不降低现有校验)。\n\n**Phase 3B(已全部完成,2026-07-09)**:模型自动映射、自动恢复探测、晋升/降级机制会直接改变真实调度决策,风险明显高于 3A,四个子项已分批全部完成。§12.2 P1.5 的 SQLite schema version / 版本化 migration / `OriginType`&`OriginTier` 旧配置 backfill / `killSwitch`+按 task class/channel disable **已于 2026-07-09 补强完成**(详见 §12.2 P1.5)。**Phase 3B-1(自动恢复探测补全)已于 2026-07-09 完成**:修复了 L2 探测子系统里此前从未打通的两个真实 bug——① `collectAll()` 每轮把新构造的画像整行 Upsert,会把 `ProbeWorker` 刚写入的 `LastProbeAt`/`ProbeSuccess`/`ConsecutiveProbeSuccess` 等字段清零,导致 6 小时探测冷却期形同虚设(修复:`carryForwardProbeFields` 在 Upsert 前搬运 Probe* 字段,不影响 L1 诊断字段的现有覆盖逻辑);② `extractRawAPIKey` 是返回 `KeyMask` 掩码值的占位 stub,探测请求实际带的是假 key(修复:新增 `APIKeyResolver` 回调 + `Manager.ResolveAPIKey`,通过遍历配置里对应渠道的 `APIKeys` 逐个计算 `KeyHashFromAPIKey` 比对来还原明文;resolver 未注入或未命中时 fail-open 跳过探测且不消耗预算)。同时补齐了 `degraded/limited → healthy` 的连续成功恢复路径(新增 `KeyEndpointProfile.ConsecutiveProbeSuccess` 计数器 + `HealthCheckConfig.ProbeRecoveryThreshold`,默认连续 2 次探测成功才恢复,避免单次探测噪声导致状态抖动;探测失败计数器清零)。**Phase 3B-3(StabilityTier 晋降级滞后)已于 2026-07-09 完成**(提交 664d3fff):在原本每轮从瞬时 1h 窗口重算的无记忆 `StabilityTier` 上加一层连续窗口滞后——新增 `KeyEndpointProfile.EffectiveStabilityTier`/`StabilityPendingTier`/`StabilityPendingStreak` 三字段 + 纯函数 `applyStabilityHysteresis`(对称阈值:连续 N 轮同值才切换生效档,`HealthCheckConfig.StabilityHysteresisWindows` 默认 2;首次画像直接采用,不等待阈值,避免新渠道卡在零值),`AggregateChannelProfile` 改读 `EffectiveStabilityTier`(空则回退 `StabilityTier`)。晋升/降级机制的数据模型仅限于 StabilityTier,不改变 QualityTier/SpeedTier/CostTier 语义。**Phase 3B-2(模型自动映射闭环)已于 2026-07-09 完成**(提交 0f02e988,随后修复一处 critical 集成 bug,提交 856372c4):新建 `ModelProfileStore`(SQLite,`model_profiles` 表)+ 扩展 `AutoDiscoveryRunner` 为每个发现模型写入 `ModelProfile`(仅 `AutoManaged` 渠道);新建 `ModelResolver` 实现 §5.4 五步逻辑(手动 `RedirectModel` 短路 → `GetModelProfiles` → `filterByCapabilityFloor` → `rankBySimilarity`);`scheduler` 包新增 `ModelSupportResolverFunc` hook(fail-open,未注入或返回不支持时严格回退到原 `ExplainModelSupport`);`endpoint_policy.go` 的 `resolveMappedModel`(此前是永不触发的死代码——`profile.ModelMapping` 从未被写入)现调用 `ModelResolver`;`handlers/common` 在 `RedirectModel` 之后应用 `MappedModel` 覆盖请求体,并按 `EchoMappedModel` 配置回显 `X-CCX-Mapped-Model`/`X-CCX-Original-Model`/`X-CCX-Mapping-Source` 响应头。三条件安全门控:`ModelMapping.AutoResolve==true`(**默认由死配置的 true 改为 false**)AND `RoutingMode ∈ {assist, auto}` AND 无 `KillSwitch`,三者任一不满足则所有新增代码路径行为与今天字节级一致;且仅 `AutoManaged` 渠道触发,手动配置渠道完全不受影响。对抗性复核发现并修复一个真实 critical bug:`scoreEndpointForKey` 用空 `channelUID` 计算 `EndpointUID` 并写入 `modelByUID`,而 `handlers` 层用真实 `channelUID` 查 `modelByUID`,两者哈希永不相等导致 `MappedModel` 在 key 级调度路径上完全查不到(提交 856372c4 修复:命中画像后改用 `profile.EndpointUID`,新增回归测试锁定该不变量)。至此 Phase 3B 四个子项(自动恢复探测、StabilityTier 滞后、模型自动映射、active filter 前置判定)全部完成;SLO regression 自动回滚仍是独立子系统,留待后续单独立项。\n\n**范围**:\n- [x] 运行时指标驱动画像实时更新(Phase 3A:`collectAll()` 每轮 diff + changelog + WebSocket 推送)\n- [x] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)—— Phase 3B-2(2026-07-09 完成,提交 0f02e988 + 856372c4)\n- [x] active model filter / context filter 支持自动映射前置判定 —— Phase 3B-2:`scheduler.ModelSupportResolverFunc` fail-open 接入 `select.go`\n- [x] 自动恢复探测(limited/dead → healthy)—— Phase 3B-1(2026-07-09 完成):`APIKeyResolver` + `carryForwardProbeFields` + `ConsecutiveProbeSuccess`/`ProbeRecoveryThreshold`\n- [x] 晋升/降级机制(连续成功→升级,连续失败→降级)—— Phase 3B-3(2026-07-09 完成,提交 664d3fff):`StabilityTier` 连续窗口滞后(`EffectiveStabilityTier`),暂不含 QualityTier/SpeedTier/CostTier\n- [x] WebSocket 推送画像变更事件(Phase 3A:`GET /api/health-center/events`)\n- [x] 前端画像变更历史/时间线(Phase 3A:`ProfileChangelogTimeline.vue`)\n\n**预估工期**:2-3 周(3A、3B-1、3B-2、3B-3 均已完成;SLO regression 自动回滚待单独立项评估工期)\n\n### Phase 4:高级特性\n\n**Phase 4 状态(2026-07-09,全部完成)**:8 项全部实现并接入真实链路,均遵循\"默认关闭 + 显式 opt-in\"安全守则。\n\n**范围**:\n- [x] 多维度标签系统扩展(用户自定义标签)——Item 1\n- [x] 成本报表:按用户/模型/渠道/key 统计真实有效成本——Item 2(新增 `proxyKeyMask` 身份贯穿链路:`middleware.GetProxyKeyMask` → `ChannelLog`/`RequestRecord` → SQLite `proxy_key_mask` 列,供 Item 4 复用)\n- [x] SLO regression 自动回滚——Item 3(默认关闭;`SLORollbackConfig`,连续 degrading 窗口数默认 3,不做自动恢复以避免震荡)\n- [x] 渠道推荐:根据使用模式推荐新渠道——Item 4\n- [x] 批量渠道管理(导入/导出/模板)——Item 5\n- [x] 订阅中心 provider adapter:可选自动刷新余额/套餐状态——Item 6(默认关闭;仅 openai/anthropic/google 且显式填写 BillingAPIKey 的订阅生效,中转/公益渠道继续手动维护)\n- [x] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板——Item 7\n- [x] A/B 测试:低比例统计抽样双发——Item 8(默认关闭,默认 1% 抽样 + 每小时 60 次硬预算;主请求路径不受影响,影子请求在主响应返回后异步发起)\n\n**关键实现说明**:\n- Item 3/6/8 均在 `AutopilotRoutingConfig` 新增独立配置块(`SLORollback`/`SubscriptionAutoRefresh`/`ABTest`),`Validate()` 统一做负值/越界兜底,保持与既有字段一致的防御模式。\n- Item 4 直接复用 Item 2 的 `proxyKeyMask` 身份标记,未重新发明身份识别机制。\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- ~~SQLite schema version 和幂等 migration。~~ **已完成**:`ensureSchemaVersion` 基于 `PRAGMA user_version`,当前 7 张表定版为 v1 基线;库版本高于代码版本时返回 error(fail-closed,不做任何写操作),交给下游 fail-open 分支接管。\n- ~~旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。~~ **已完成**:`ensureOriginBackfill` 只补 `\"unknown\"`,不做任何基于 URL/名称的猜测推断;已接线到 `Profiler.DeriveEndpointProfile`,`BreakTieByOriginTier` 现在能吃到真实数据(此前一直是 fallback 的 `unknown`)。\n- ~~profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。~~ **已确认满足并测试锁定**:`main.go` 现有结构本就是 fail-open(`NewProfileStore`/`NewManager` 出错只打日志、`autopilotManager` 保持 `nil`,下游全部有 `nil` 守卫),本次新增 `schema_migration_test.go` 用测试锁定这条不变量。\n- ~~全局 `killSwitch`、按 task class disable、按 channel disable。~~ **已完成**:新增 `AutopilotRoutingConfig.DisabledTaskClasses`/`DisabledChannelUIDs`;`CandidateFilterFor`/`executeFilter`/`BuildPlan` 三处一致接入,`smart_router_invariant_test.go` 覆盖\"task class 禁用后回退默认调度\"\"channel 禁用后不出现在候选里\"\"dry-run 与真实路径行为一致\"三条不变量。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。**待实现**:`TrustedRoutingAdvisor.promotionMode` 目前是 `manual`,shadow→candidate→active 已有人工审查关卡;自动回滚是独立的新子系统(需要基线捕获窗口 + 实时对比 + 阈值判定),留待下一轮单独设计落地。\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | shadow 样本/准确率/critical misroute 门槛;active 后 SLO regression 自动 rollback;全局 kill switch |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "5d2ea67008b8062dd3b4f7104a16827e50c393b3": {"t": 1785759362.0379176, "n": 156960, "h": "756f3f2b1b2834bf", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.2.3 同订阅多 Key:能力共享与配额负载均衡\n\n**问题**:官方订阅 plan(Claude Max、Codex plan 等)允许用户持有多个 key,或购买多份相同套餐。若每个 endpoint 独立探测与配置,探测预算被浪费、配置重复;请求量大时还需要在同 plan 的多个 key 之间按官方 RPM 限速自动负载均衡,而不是打满第一个 key 才溢出到下一个。\n\n**能力检测去重(同一套餐只需一套能力检测和配置)**:\n\n```text\nSubscriptionUID 相同的 endpoint 共享一份「订阅级能力画像」:\n 模型列表 / vision / tool / reasoning 支持 / 协议兼容开关 → 只探测一次,\n 存在 SubscriptionProfile.SharedCapability,endpoint 画像以引用方式继承。\n\n仍然 per-endpoint 维护的:健康状态、延迟、熔断、用量窗口\n (key 可能被单独封禁/限速,能力相同不代表运行态相同)\n\n继承失效条件:某 key 实测模型列表哈希与订阅级不一致(GroupChangeDetector 信号)\n → 该 key 脱离继承、独立探测,并在 UI 标记「与套餐能力不一致」警告。\n```\n\n**配额负载均衡(Phase 1 shadow 展示,Phase 2 生效)**:\n\n```text\n每 key 维护配额余量视图:\n remainingRPM = officialRPM − 当前窗口已用\n officialRPM 来源优先级:内置 manifest > 用户配置 > RateLimitDiscoverer 建议\n\n同订阅多 key 时按「剩余余量加权」分配请求(余量大者优先、\n窗口最早重置者兜底),替代顺序打满:\n 仅当渠道 autoManaged 且同一 SubscriptionUID 下 key 数 > 1 时启用;\n 否则保持现有 sequential/random key 顺序策略不变。\n\n某 key 的窗口配额耗尽 → 临时降权并切到同订阅其他 key,\n不熔断整个渠道(这是配额耗尽,不是故障)。\n```\n\n### 3.2.4 订阅用量窗口 (Usage Windows)\n\n部分官方 plan 提供 5h / 日 / 周 / 月等窗口的用量查询与限制(如 Claude 订阅的 5h 滚动窗口 + 周配额)。在 key 的位置展示这些窗口,帮助用户掌握使用情况:\n\n```go\ntype UsageWindow struct {\n Window string `json:\"window\"` // \"5h\" | \"day\" | \"week\" | \"month\"\n Used float64 `json:\"used\"` // 已用量\n Limit float64 `json:\"limit\"` // 上限,0 = 未知\n Unit string `json:\"unit\"` // requests | tokens | credits | percent\n ResetsAt time.Time `json:\"resetsAt\"` // 窗口重置时间\n Source string `json:\"source\"` // official_api | response_header | local_metering\n FetchedAt time.Time `json:\"fetchedAt\"`\n}\n// KeyEndpointProfile 与 SubscriptionProfile 均增加 UsageWindows []UsageWindow\n```\n\n数据来源优先级:\n\n```text\n1. 官方用量 API:有 provider adapter 的 plan 主动拉取(§Phase 4 订阅 adapter 的可提前子集)\n2. 响应头:anthropic-ratelimit-* / x-ratelimit-* 等(复用 RateLimitDiscoverer 的信号 tap)\n3. 本地计量兜底:CCX 按 key 累计请求数/token(误差来自窗口起点对齐,标注 Source=local_metering)\n```\n\nUI 要求:key 行内显示主窗口(5h)用量进度条,悬浮展开全部窗口;用量 >80% 黄色预警、超限红色并显示预计重置时间。调度联动(Phase 2):窗口余量进入配额负载均衡权重,窗口耗尽的 key 临时降权而非熔断。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n#### 4.2.1 内置模型清单 (Builtin Models Manifest)\n\n**问题**:部分官方订阅 plan 的入口没有正确的 models 接口——要么不存在(404)、要么需要额外权限、要么返回与订阅实际可用模型不符的列表(典型:Claude 订阅 OAuth 入口、Codex plan 入口、部分官方企业网关)。对这些 baseURL 做模型发现会失败或产出错误画像。\n\n**方案**:CCX 内置「已知官方 baseURL → 模型清单」映射表,命中时直接返回内置内容,完全跳过上游 models 接口:\n\n```go\n// backend-go/internal/config/builtin_models_manifest.go(生成式,随模型注册表更新)\ntype BuiltinModelsManifest struct {\n BaseURLPattern string `json:\"baseUrlPattern\"` // 精确 host 或前缀匹配,如 \"api.anthropic.com\"\n ServiceType string `json:\"serviceType\"` // messages/responses/…\n PlanHint string `json:\"planHint,omitempty\"` // 订阅类型提示(如 \"claude_subscription\")\n ModelIDs []string `json:\"modelIds\"` // 该入口实际可用的模型清单\n DisableProbe bool `json:\"disableProbe\"` // true 时 Discovery 跳过 GET /v1/models\n}\n```\n\n**两个生效位置**:\n\n```text\n1. Discovery(§4.2 流程第 2 步之前):\n baseURL 命中 manifest 且 DisableProbe=true\n → 跳过 GET /v1/models,直接用 ModelIDs 生成 ModelProfile[]\n → ProfileSource = \"builtin_manifest\",不再因 models 接口失败而误判 misconfigured\n\n2. /v1/models 代理响应(现有 Models API):\n 客户端向 CCX 请求 /v1/models 时,聚合结果中该渠道的贡献\n 直接取内置清单,不向上游发起 models 请求\n (渠道显式配置了 supportedModels 时仍以用户配置优先)\n```\n\n**维护与覆盖顺序**:\n\n```text\n渠道显式 supportedModels(用户手动) > 内置 manifest > 上游 models 接口实探 > 内置候选模型回退\n```\n\nmanifest 与模型注册表同源生成(`generated_model_registry.go` 同级),新模型发布时随注册表一起更新;HealthAnalyzer 对命中 manifest 的 endpoint 不将「models 接口 404/403」计入 misconfigured 信号。\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法细分时进入 `general`;有规范 Knowledge 基准则使用其软评分,缺少直接证据时仍取 0.5 中性值。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n**手动 L3 第一版状态(2026-07-15)**:已实现 `pq-v1-20260715` 固定 JSON canary,覆盖\nMessages、Chat Completions、Responses 与 Gemini 上游协议。调用方只能指定 `endpointUid`、\n`modelId` 和 1~3 次重复数,不能注入 prompt。评分仍按 40% 完整性、30% 语义正确性、\n15% 严格 JSON 格式、15% 延迟执行;第一版使用非流式完整响应延迟作为首 token 延迟代理,\n后续引入流式采样时再替换该维度。单次有效样本 confidence=0.6,最多三次提升到 0.8;\n失败样本按 0 分计入均值,全部失败不写画像。\n\n安全边界:仅管理 API 手动触发,不启动自动 worker;当前进程按 UTC 日设置 12 次硬预算,\n请求前原子预留,单次 90 秒超时、响应体上限 64 KiB。同一 endpoint×model 不并发执行;\n单次请求最多允许 2048 个输出 token,以容纳推理模型的隐藏思考并限制最坏额度消耗。\n对声明可控推理的模型固定采用最低可用 effort;Claude 兼容端点若只暴露\n`reasoning_content` 而未声明 thinking 模式,则与 capability-test 一致显式关闭 thinking。\n原始模型输出和明文 Key 既不落盘也不返回,只保存分数、canary 版本、置信度和脱敏判定。\n`user_feedback` 仍高于 `probe`,自动发现重建 ModelProfile 时必须保留既有质量证据。\n跨进程共享预算与 LLM-as-judge/语义向量评分仍属于后续阶段。\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设 + 自定义\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n| `custom` | 用户自定义 | 用户自定义 | 高级用户微调,范围 0.0 ~ 3.0 |\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法细分的通用任务;缺少基准证据时中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general;有规范 Knowledge 基准时使用其软评分,缺少直接证据时取 0.5 中性。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? canonicalBenchmark[model][domain] * providerQualityFactor\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 规范模型基准 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n#### 5.7.4 规范模型能力基准\n\n`shared/model-registry/ccx_model_registry.json` 的 `benchmarkProfiles` 独立保存规范模型能力上界,不与 `upstreamCapabilities` 混合。后者仍是 context、vision、tool calls 等硬能力的事实源;benchmark 只参与 Autopilot 软评分和 trace 解释,绝不改变 `supportedModels` 或 CapabilityFloor。\n\n```text\nEffectiveCapability[domain]\n = CanonicalCeiling[model, domain]\n × ProviderQualityFactor[endpoint]\n\nProviderQualityFactor = 1 - confidence × (1 - providerQualityScore)\n无渠道质量证据或 confidence<0.5 时 factor=1;可信证据只能从规范模型上界向下折算。\n```\n\n基准数据必须保留领域向量、来源、核验日期、lane、共享结果数和可比类别覆盖率,不能只保存站点 Overall。当前 BenchLM 接入为 `provisional`:Claude Opus 4.8、GPT-5.6 Terra、GPT-5.6 Sol 各有 30 个共享结果,8 类中 5 类可比,核验日期为 2026-07-13。\n\n领域映射:Knowledge → `general`,Math → `reasoning`,Coding → `coding` / `code_review`,Agentic → `agentic`,Multimodal → `aesthetics_ui`(仅弱代理,解释置信度减半)。Writing / Translation 没有直接类别证据时继续回退家族种子或 0.5。BenchLM 的 cohort 归一化、缺失项插补和外部校准决定了 Overall 不是绝对能力值,因此路由只消费可映射的原始类别分。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选最佳匹配\n // 匹配优先级:\n // a. 同模型族(opus→opus, sonnet→sonnet)—— 最高优先\n // b. 同质量档(premium→premium)\n // c. 上下文窗口最接近(不超也不差太多)\n // d. 探测延迟最低\n best := rankBySimilarity(eligible, requestModel, floor)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\nGET /api/health-center/provider-quality/budget → 查看本进程手动 L3 每日预算\nPOST /api/health-center/provider-quality/probe → 对指定 endpoint×model 执行固定质量 canary\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n#### 8.0.1 实现现状与演进方向(2026-07-09 核实)\n\n四界面在 **Web 前端(`frontend/src`)已大面积落地**,不是纯文档:\n\n| 界面 | Web 实现 | Desktop 实现 |\n|------|----------|--------------|\n| 健康中心 | ✅ `HealthCenterView.vue` + `HealthCenterStats`/`HealthChannelTable`/`HealthChannelDetail`/`ProfileChangelogTimeline`,路由 `/health` | ❌ 无 |\n| 订阅中心 | ✅ `SubscriptionsView.vue` + `SubscriptionPlanTable`,CRUD + 刷新余额,路由 `/subscriptions` | ⚠️ 独立旧实现 `subscriptions/SubscriptionTab.vue`(与 Web 两套代码) |\n| 渠道中心 | ✅ `ChannelOrchestration.vue`(六态 `ChannelHealthBadge`、来源 chips、`autoManaged` 图标齐备) | ❌ 未同步 |\n| 管理面板 | ⚠️ `AutopilotModePanel.vue` 仅暴露 mode/killSwitch/costPreference | ❌ 无 |\n| 驾驶舱 | ✅ `CockpitView.vue`(观测型总览:健康统计+余额汇总+渠道推荐) | ⚠️ `cockpit` tab 渲染的是会话 Dashboard,职责不同 |\n\n**与本设计意图的关系**(对齐用户「自动调度为主、废旧手动驾驶舱」的诉求):\n- **旧的「手动调度驾驶舱」在 Web 端已不存在**。`CockpitView` 已是 §8.7 定义的观测型总览,只回答健康/省钱/风险/待办四问题,不做渠道配置编辑,符合「程序自动选、人只观测+有限干预」的方向。无需再废除,只需继续收敛为纯观测。\n- **统一提交入口已存在**(§8.4 快速添加 + §8.5 订阅中心),new-api 集成(§8.5.1)在此之上把「填 baseURL+key」进一步自动化。\n\n**前端演进待办**(本设计新增/明确):\n1. **补 `ManualRoutingIntent` 前端**:这是用户要的「有限偏向性干预」,后端已有、两个前端均未实现(§8.3 试用意图入口尚缺)。\n2. **Desktop 前端对齐 Web**:健康中心/渠道自动托管/管理面板/观测型驾驶舱在 Desktop 几乎空白,需同步或复用 Web 组件,避免长期维护两套。\n3. **管理面板补字段**:暴露 `l2ProbeEnabled`、探测预算、new-api/多来源订阅 adapter 开关(§8.5.2)等,当前只有 3 个字段。\n4. **订阅中心接 §8.5.1/§8.5.2**:新增「new-api」订阅类型与 Provider Adapter 能力矩阵驱动的动态表单。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.5.1 new-api 订阅集成(统一账号接入)\n\n**目标**:用户在订阅中心手填一个 new-api(及其 fork)账号的「系统访问令牌」即可,CCX 自动用该令牌创建代理专用 key、拉取分组倍率、查询额度余额,并把「站点 baseURL + 新建 key」自动落成一个上游渠道,交给 Autopilot 探测与调度。\n\n**范围约束**:\n- 只使用 new-api 用户在「个人设置」生成的 **系统访问令牌(access_token)** 一种凭据;令牌用于 API 调用的身份验证,属敏感数据,须妥善加密存储。\n- **不做** cookie 登录 + 2FA + 导出后台渠道明文 key 的管理员迁移路径(all-api-hub 的 newApiSession 能力),本设计不涉及。\n- new-api 归入 `OriginType=relay`、`OriginTier=second`(中转站),信任等级不因自动化提升;实时质量仍由运行画像决定。\n- 认证令牌像其他订阅凭据一样由用户手动录入,CCX 不代替用户在 new-api 侧注册或登录。\n\n**认证与凭据模型**:\n\nnew-api family 的管理接口鉴权(依据 all-api-hub 传输层实现):\n\n```text\nAuthorization: Bearer # 系统访问令牌;raw 模式可去掉 Bearer 前缀\nNew-API-User: # 当前用户 ID(必带)\nUser-id: # fork 兼容回退(Veloera/voapi/Super-API 等)\nContent-Type: application/json\n```\n\n统一响应信封:`{ \"success\": bool, \"data\": T, \"message\": string }`;解析时先判 `success`,再取 `data`。\n\n**SubscriptionProfile 扩展**:\n\n在 §3.2.2 的 `SubscriptionProfile` 基础上,为可自动接入的订阅新增 provider 集成字段(沿用「默认关闭 + 显式 opt-in」守则):\n\n```go\n// SubscriptionProfile 新增字段(复用 Phase 4 Item 6 的 SubscriptionAutoRefresh 开关)\ntype SubscriptionIntegration struct {\n Kind string `json:\"kind\"` // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | ...\n BaseURL string `json:\"baseUrl\"` // new-api 站点地址(同时作为新建渠道的上游 baseURL)\n AccessToken string `json:\"-\"` // 系统访问令牌;加密存储,不出 API 响应\n UserID string `json:\"userId\"` // New-API-User 头所需\n AuthTokenMode string `json:\"authTokenMode,omitempty\"` // \"bearer\"(默认) | \"raw\"\n // 自动建 key 的模板\n ProvisionKeyName string `json:\"provisionKeyName,omitempty\"` // 默认 \"ccx-autopilot\"\n ProvisionGroup string `json:\"provisionGroup,omitempty\"` // 建 key 时指定分组,空=默认分组\n ProvisionModels []string `json:\"provisionModels,omitempty\"` // model_limits,空=不限制\n // 自动同步产物(回填,只读展示)\n ProvisionedTokenID int `json:\"provisionedTokenId,omitempty\"`\n LinkedChannelUID string `json:\"linkedChannelUid,omitempty\"`\n}\n```\n\n`AccessToken` 必须与现有敏感字段一样走加密存储,序列化到前端时脱敏(仅显示尾部若干位)。\n\n**new-api 接口清单**(本集成实际调用的子集,路径以主线 new-api 为准,字段名保持与上游一致):\n\n| 用途 | 方法 | 路径 | 请求体 | 响应关键字段 |\n|------|------|------|--------|--------------|\n| 校验令牌 + 查用户/余额 | GET | `/api/user/self` | — | `data.id`、`data.username`、`data.quota`(剩余额度)、`data.used_quota` |\n| 拉分组倍率 | GET | `/api/user/self/groups` | — | `data: { : { desc, ratio } }` |\n| 站点全部分组 | GET | `/api/group` | — | `data: string[]` |\n| 账号可用模型 | GET | `/api/user/models` | — | `data: string[]` |\n| 创建代理 key | POST | `/api/token/` | `CreateTokenRequest`(见下) | `data.key` / `success` |\n| key 列表 | GET | `/api/token/?p={p}&size={n}` | — | `data.items[]`(含 `id/key/name/status/remain_quota`) |\n| 更新 key | PUT | `/api/token/` | `{ ...token, id }` | `success` |\n| 删除 key | DELETE | `/api/token/{id}` | — | `success` |\n\n`CreateTokenRequest`:\n\n```json\n{\n \"name\": \"ccx-autopilot\",\n \"remain_quota\": 0,\n \"expired_time\": -1,\n \"unlimited_quota\": true,\n \"model_limits_enabled\": false,\n \"model_limits\": \"\",\n \"allow_ips\": \"\",\n \"group\": \"\"\n}\n```\n\n`quota` 是 new-api 内部额度单位(非美元),换算比例读 `GET /api/status` 的 `price` / `PaymentUSDRate`(无鉴权),或允许用户在订阅中手填换算率;MVP 直接以 quota 原值展示 + 站点换算率估算余额。\n\n**接入流程**(订阅中心「新增订阅 → 类型选 new-api」):\n\n```text\n1. 用户输入:站点 baseURL + 系统访问令牌(access_token) + userId\n (userId 可选自动获取:先用令牌调 /api/user/self 拿 data.id 回填)\n2. 校验:GET /api/user/self\n ├─ success=false / 401 → 令牌无效或 userId 不匹配,报错停止\n └─ 成功 → 记录 username、quota,创建 SubscriptionProfile(provider=new_api, originTier=second)\n3. 拉分组:GET /api/user/self/groups → 写入 GroupMultipliers({group: ratio})\n GET /api/user/models → 记录账号可用模型,供渠道 supportedModels 参考\n4. 建 key:POST /api/token/(按 ProvisionKeyName/Group/Models 模板)\n ├─ 已存在同名 ccx-autopilot key → 复用其 id,不重复创建(列表比对 name)\n └─ 新建成功 → 回填 ProvisionedTokenID,取 data.key 作为渠道 apiKey\n5. 自动建渠道:用 baseURL + 新建 key 创建一个上游渠道\n ├─ channelKind 由用户选择或按站点探测(messages/chat/responses/gemini)\n ├─ status=unknown,autoManaged=true,OriginType=relay\n ├─ 建立 SubscriptionUID → ChannelUID 链接(回填 LinkedChannelUID)\n └─ 触发 §8.4 的 Discovery + 能力测试标准流程\n6. 完成:订阅卡片显示余额、分组倍率、已绑定渠道、探测进度\n```\n\n关键点:new-api 集成**不额外造探测逻辑**,第 5 步之后完全复用现有 §8.4「添加并探测」链路;集成层只负责「令牌 → key + 分组 + 余额 + baseURL」的自动填充,把手填 baseURL/key 的动作自动化掉。\n\n**刷新与同步**(复用 `POST /api/subscriptions/{uid}/refresh`):\n\n| 触发 | 动作 |\n|------|------|\n| 手动刷新余额 | `GET /api/user/self` 更新 `Balance` + `BalanceUpdatedAt` |\n| 定时刷新(可选,opt-in) | 复用 Phase 4 Item 6 `SubscriptionAutoRefresh`;new-api 作为 `relay` provider adapter 纳入,默认关闭 |\n| 分组倍率变更 | 刷新时对比 `/api/user/self/groups`,`GroupMultipliers` 变化 → 更新并提示「套餐倍率已变」 |\n| key 失效检测 | 渠道侧探测到该 key 全 401 → 订阅卡片提示「key 可能已删除/禁用」,提供「重新建 key」一键动作(重跑第 4-5 步,复用旧渠道,只换 apiKey) |\n| 可用模型变更 | `/api/user/models` 结果哈希变化 → 触发对应渠道重新 Discovery |\n\n**安全与边界**:\n- `access_token` 权限等同 new-api 账户,须加密存储、脱敏展示、禁止写入日志;刷新失败连续 N 次自动停用该订阅的自动刷新,只保留手动。\n- 自动建 key 默认 `unlimited_quota=true` 但可由用户改为限额模板;建 key 是有副作用的写操作,须在接入前明确告知用户「将在 new-api 侧创建一个名为 ccx-autopilot 的令牌」。\n- new-api 是 `OriginTier=second`,**不得**用作隐私敏感的 routing advisor / classifier(遵守 §3.2.1 约束)。\n- fork 兼容:请求同时带 `New-API-User` 与 `User-id` 头;响应信封字段名(`success/data/message`)在部分 fork 可能不同,adapter 需容错解析,失败时回退手动录入。\n- 一个 new-api 订阅默认只自动建/绑定一个渠道;用户如需该站多协议渠道,可在同订阅下手动增建,共用同一 key。\n\n### 8.5.2 订阅 Provider Adapter 框架(多来源余额与接入)\n\n§8.5.1 的 new-api 集成不是特例,而是「订阅 Provider Adapter」框架的一个实现。Phase 4 Item 6 已落地 openai/anthropic/google 的余额刷新 adapter,本框架把它抽象成统一接口,让 new-api 及后续来源(GitHub Copilot、Codex、Nvidia、MiniMax、DeepSeek 等)按相同契约接入,避免每加一个来源就散落一套逻辑(DRY)。\n\n**统一接口**:\n\n```go\n// backend-go/internal/autopilot/subscription/adapter.go\ntype ProviderAdapter interface {\n Kind() string // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | \"copilot\" | \"deepseek\" | ...\n\n // 校验凭据,返回账号身份(用户名/账户标识);失败即凭据无效\n Verify(ctx context.Context, cred Credential) (AccountInfo, error)\n // 查余额/额度;不支持返回 ErrUnsupported,由 UI 降级为手动录入\n FetchBalance(ctx context.Context, cred Credential) (Balance, error)\n // 拉分组倍率;仅 new-api 类中转有,官方直连返回 ErrUnsupported\n FetchGroups(ctx context.Context, cred Credential) (map[string]GroupRatio, error)\n // 自动创建代理 key;官方直连通常不支持(key 即用户 API key),返回 ErrUnsupported\n ProvisionKey(ctx context.Context, cred Credential, tmpl KeyTemplate) (ProvisionedKey, error)\n // 可用模型列表;用于渠道 supportedModels 参考\n FetchModels(ctx context.Context, cred Credential) ([]string, error)\n}\n```\n\n每个能力独立、可选:adapter 只实现自己支持的部分,不支持的统一返回 `ErrUnsupported`(接口隔离原则),UI 据此隐藏/降级对应入口。所有 adapter 共用 `SubscriptionAutoRefresh` 的 opt-in 开关与 `Validate()` 兜底,与 Phase 4 现有防御模式一致。\n\n**能力矩阵**(✅ 支持 / ⚠️ 部分或需确认 / ❌ 不支持,走手动):\n\n| 来源 | Kind | 验证 | 查余额 | 拉分组 | 自动建 key | OriginTier |\n|------|------|:----:|:------:|:------:|:----------:|------------|\n| new-api family | `new_api` | ✅ | ✅ quota | ✅ | ✅ | second |\n| OpenAI 官方 | `openai` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Anthropic 官方 | `anthropic` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Google 官方 | `google` | ✅ | ⚠️ | ❌ | ❌ | first |\n| GitHub Copilot | `copilot` | ⚠️ token | ❌ 无额度概念 | ❌ | ❌ | first |\n| Codex plan | `codex` | ⚠️ | ⚠️ 用量窗口 | ❌ | ❌ | first |\n| Nvidia NIM | `nvidia` | ✅ | ⚠️ credits | ❌ | ❌ | first |\n| MiniMax | `minimax` | ✅ | ⚠️ | ❌ | ❌ | first |\n| DeepSeek | `deepseek` | ✅ | ✅ balance API | ❌ | ❌ | first |\n\n说明:官方直连来源普遍**不支持自动建 key**(用户的 API key 本身就是凭据,直接作为渠道 apiKey 录入即可)、也**没有分组倍率**(价格按官方定价表)。它们的 adapter 价值集中在「验证 + 查余额/用量窗口」,把 §3.2.4 UsageWindow 的官方用量 API 来源坐实。DeepSeek 有独立余额查询端点,可完整支持余额。Copilot 属订阅制、无 token 余额概念,只做验证 + 用量提示。\n\n**接入分层**(YAGNI:不强求一次做全,按价值排期):\n\n```text\nP0(本轮):new_api(完整:验证+余额+分组+建key+自动建渠道)\n —— 覆盖用户最主要的中转站场景\nP1:deepseek(验证+余额)、官方三家余额已在 Phase 4 完成 → 补 UsageWindow 对齐\nP2:nvidia / minimax(验证+credits/余额)\nP3:copilot / codex(验证+用量窗口提示,无余额)\n```\n\n每个来源的具体端点在实现时于 `adapter.go` 各 provider 文件内落地,本设计只固定接口契约与能力矩阵,避免文档与上游 API 变动强耦合。新增来源 = 实现 `ProviderAdapter` + 在能力矩阵登记,不改框架。\n\n**能力缺失与降级**:\n- adapter 任一能力返回 `ErrUnsupported` → 订阅中心对应字段回退为手动录入(余额手填、倍率手填),不报错、不阻塞接入。\n- adapter 验证失败/超时 → 订阅可仍以「手动模式」创建,仅失去自动刷新与自动建 key,其余流程(手填 baseURL+key 建渠道)不受影响。\n- 官方来源无「自动建 key」时,接入流程退化为 §8.4 标准快速添加:用户填 API key,adapter 只补验证与余额。\n- 所有自动刷新遵守 `SubscriptionAutoRefresh` 默认关闭;连续失败自动停用自动刷新并在驾驶舱「人工待办」提示。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3,\n \"providerTimePricing\": {\n \"deepseek\": {\n \"effectiveFrom\": \"2026-07-20T00:00:00+08:00\",\n \"timeZone\": \"Asia/Shanghai\",\n \"peakMultiplier\": 2,\n \"peakWindows\": [\n { \"start\": \"09:00\", \"end\": \"12:00\" },\n { \"start\": \"14:00\", \"end\": \"18:00\" }\n ]\n }\n }\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n`providerTimePricing.effectiveFrom` 使用 RFC3339;修改该值即可调整激活时间,设为空字符串则停用对应规则。\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**Phase 3A 状态(2026-07-09)**:低风险、不改真实路由语义的子集已完成并接入真实链路——`Manager.collectAll()` 新旧画像对比产出变更事件(`DetectProfileChanges`)、`ProfileChangelogStore`(环形内存 + SQLite,30 天机会性清理)、`EventHub` 内存 pub/sub、`GET /api/health-center/changelog`(历史)+ `GET /api/health-center/events`(WebSocket 实时推送)、`AutoDiscoveryRunner` 发布 `discovery_completed`/`auto_mapping_applied`、前端 `ProfileChangelogTimeline.vue` 接入健康中心页并支持断线自动重连。均为只读展示,未修改 `SmartRouter`/`EndpointAttemptPolicy` 调度逻辑。落地时顺带发现并修复一个真实缺口:浏览器原生 WebSocket 无法设自定义 header,为此给 `middleware.getAPIKey` 增加了 `Sec-WebSocket-Protocol` 鉴权回退(业界标准做法,仅新增一个 key 来源,不降低现有校验)。\n\n**Phase 3B(已全部完成,2026-07-09)**:模型自动映射、自动恢复探测、晋升/降级机制会直接改变真实调度决策,风险明显高于 3A,四个子项已分批全部完成。§12.2 P1.5 的 SQLite schema version / 版本化 migration / `OriginType`&`OriginTier` 旧配置 backfill / `killSwitch`+按 task class/channel disable **已于 2026-07-09 补强完成**(详见 §12.2 P1.5)。**Phase 3B-1(自动恢复探测补全)已于 2026-07-09 完成**:修复了 L2 探测子系统里此前从未打通的两个真实 bug——① `collectAll()` 每轮把新构造的画像整行 Upsert,会把 `ProbeWorker` 刚写入的 `LastProbeAt`/`ProbeSuccess`/`ConsecutiveProbeSuccess` 等字段清零,导致 6 小时探测冷却期形同虚设(修复:`carryForwardProbeFields` 在 Upsert 前搬运 Probe* 字段,不影响 L1 诊断字段的现有覆盖逻辑);② `extractRawAPIKey` 是返回 `KeyMask` 掩码值的占位 stub,探测请求实际带的是假 key(修复:新增 `APIKeyResolver` 回调 + `Manager.ResolveAPIKey`,通过遍历配置里对应渠道的 `APIKeys` 逐个计算 `KeyHashFromAPIKey` 比对来还原明文;resolver 未注入或未命中时 fail-open 跳过探测且不消耗预算)。同时补齐了 `degraded/limited → healthy` 的连续成功恢复路径(新增 `KeyEndpointProfile.ConsecutiveProbeSuccess` 计数器 + `HealthCheckConfig.ProbeRecoveryThreshold`,默认连续 2 次探测成功才恢复,避免单次探测噪声导致状态抖动;探测失败计数器清零)。**Phase 3B-3(StabilityTier 晋降级滞后)已于 2026-07-09 完成**(提交 664d3fff):在原本每轮从瞬时 1h 窗口重算的无记忆 `StabilityTier` 上加一层连续窗口滞后——新增 `KeyEndpointProfile.EffectiveStabilityTier`/`StabilityPendingTier`/`StabilityPendingStreak` 三字段 + 纯函数 `applyStabilityHysteresis`(对称阈值:连续 N 轮同值才切换生效档,`HealthCheckConfig.StabilityHysteresisWindows` 默认 2;首次画像直接采用,不等待阈值,避免新渠道卡在零值),`AggregateChannelProfile` 改读 `EffectiveStabilityTier`(空则回退 `StabilityTier`)。晋升/降级机制的数据模型仅限于 StabilityTier,不改变 QualityTier/SpeedTier/CostTier 语义。**Phase 3B-2(模型自动映射闭环)已于 2026-07-09 完成**(提交 0f02e988,随后修复一处 critical 集成 bug,提交 856372c4):新建 `ModelProfileStore`(SQLite,`model_profiles` 表)+ 扩展 `AutoDiscoveryRunner` 为每个发现模型写入 `ModelProfile`(仅 `AutoManaged` 渠道);新建 `ModelResolver` 实现 §5.4 五步逻辑(手动 `RedirectModel` 短路 → `GetModelProfiles` → `filterByCapabilityFloor` → `rankBySimilarity`);`scheduler` 包新增 `ModelSupportResolverFunc` hook(fail-open,未注入或返回不支持时严格回退到原 `ExplainModelSupport`);`endpoint_policy.go` 的 `resolveMappedModel`(此前是永不触发的死代码——`profile.ModelMapping` 从未被写入)现调用 `ModelResolver`;`handlers/common` 在 `RedirectModel` 之后应用 `MappedModel` 覆盖请求体,并按 `EchoMappedModel` 配置回显 `X-CCX-Mapped-Model`/`X-CCX-Original-Model`/`X-CCX-Mapping-Source` 响应头。三条件安全门控:`ModelMapping.AutoResolve==true`(**默认由死配置的 true 改为 false**)AND `RoutingMode ∈ {assist, auto}` AND 无 `KillSwitch`,三者任一不满足则所有新增代码路径行为与今天字节级一致;且仅 `AutoManaged` 渠道触发,手动配置渠道完全不受影响。对抗性复核发现并修复一个真实 critical bug:`scoreEndpointForKey` 用空 `channelUID` 计算 `EndpointUID` 并写入 `modelByUID`,而 `handlers` 层用真实 `channelUID` 查 `modelByUID`,两者哈希永不相等导致 `MappedModel` 在 key 级调度路径上完全查不到(提交 856372c4 修复:命中画像后改用 `profile.EndpointUID`,新增回归测试锁定该不变量)。至此 Phase 3B 四个子项(自动恢复探测、StabilityTier 滞后、模型自动映射、active filter 前置判定)全部完成;SLO regression 自动回滚仍是独立子系统,留待后续单独立项。\n\n**范围**:\n- [x] 运行时指标驱动画像实时更新(Phase 3A:`collectAll()` 每轮 diff + changelog + WebSocket 推送)\n- [x] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)—— Phase 3B-2(2026-07-09 完成,提交 0f02e988 + 856372c4)\n- [x] active model filter / context filter 支持自动映射前置判定 —— Phase 3B-2:`scheduler.ModelSupportResolverFunc` fail-open 接入 `select.go`\n- [x] 自动恢复探测(limited/dead → healthy)—— Phase 3B-1(2026-07-09 完成):`APIKeyResolver` + `carryForwardProbeFields` + `ConsecutiveProbeSuccess`/`ProbeRecoveryThreshold`\n- [x] 晋升/降级机制(连续成功→升级,连续失败→降级)—— Phase 3B-3(2026-07-09 完成,提交 664d3fff):`StabilityTier` 连续窗口滞后(`EffectiveStabilityTier`),暂不含 QualityTier/SpeedTier/CostTier\n- [x] WebSocket 推送画像变更事件(Phase 3A:`GET /api/health-center/events`)\n- [x] 前端画像变更历史/时间线(Phase 3A:`ProfileChangelogTimeline.vue`)\n\n**预估工期**:2-3 周(3A、3B-1、3B-2、3B-3 均已完成;SLO regression 自动回滚待单独立项评估工期)\n\n### Phase 4:高级特性\n\n**Phase 4 状态(2026-07-09,全部完成)**:8 项全部实现并接入真实链路,均遵循\"默认关闭 + 显式 opt-in\"安全守则。\n\n**范围**:\n- [x] 多维度标签系统扩展(用户自定义标签)——Item 1\n- [x] 成本报表:按用户/模型/渠道/key 统计真实有效成本——Item 2(新增 `proxyKeyMask` 身份贯穿链路:`middleware.GetProxyKeyMask` → `ChannelLog`/`RequestRecord` → SQLite `proxy_key_mask` 列,供 Item 4 复用)\n- [x] SLO regression 自动回滚——Item 3(默认关闭;`SLORollbackConfig`,连续 degrading 窗口数默认 3,不做自动恢复以避免震荡)\n- [x] 渠道推荐:根据使用模式推荐新渠道——Item 4\n- [x] 批量渠道管理(导入/导出/模板)——Item 5\n- [x] 订阅中心 provider adapter:可选自动刷新余额/套餐状态——Item 6(默认关闭;仅 openai/anthropic/google 且显式填写 BillingAPIKey 的订阅生效,中转/公益渠道继续手动维护)\n- [x] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板——Item 7\n- [x] A/B 测试:低比例统计抽样双发——Item 8(默认关闭,默认 1% 抽样 + 每小时 60 次硬预算;主请求路径不受影响,影子请求在主响应返回后异步发起)\n\n**关键实现说明**:\n- Item 3/6/8 均在 `AutopilotRoutingConfig` 新增独立配置块(`SLORollback`/`SubscriptionAutoRefresh`/`ABTest`),`Validate()` 统一做负值/越界兜底,保持与既有字段一致的防御模式。\n- Item 4 直接复用 Item 2 的 `proxyKeyMask` 身份标记,未重新发明身份识别机制。\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- ~~SQLite schema version 和幂等 migration。~~ **已完成**:`ensureSchemaVersion` 基于 `PRAGMA user_version`,当前 7 张表定版为 v1 基线;库版本高于代码版本时返回 error(fail-closed,不做任何写操作),交给下游 fail-open 分支接管。\n- ~~旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。~~ **已完成**:`ensureOriginBackfill` 只补 `\"unknown\"`,不做任何基于 URL/名称的猜测推断;已接线到 `Profiler.DeriveEndpointProfile`,`BreakTieByOriginTier` 现在能吃到真实数据(此前一直是 fallback 的 `unknown`)。\n- ~~profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。~~ **已确认满足并测试锁定**:`main.go` 现有结构本就是 fail-open(`NewProfileStore`/`NewManager` 出错只打日志、`autopilotManager` 保持 `nil`,下游全部有 `nil` 守卫),本次新增 `schema_migration_test.go` 用测试锁定这条不变量。\n- ~~全局 `killSwitch`、按 task class disable、按 channel disable。~~ **已完成**:新增 `AutopilotRoutingConfig.DisabledTaskClasses`/`DisabledChannelUIDs`;`CandidateFilterFor`/`executeFilter`/`BuildPlan` 三处一致接入,`smart_router_invariant_test.go` 覆盖\"task class 禁用后回退默认调度\"\"channel 禁用后不出现在候选里\"\"dry-run 与真实路径行为一致\"三条不变量。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。**待实现**:`TrustedRoutingAdvisor.promotionMode` 目前是 `manual`,shadow→candidate→active 已有人工审查关卡;自动回滚是独立的新子系统(需要基线捕获窗口 + 实时对比 + 阈值判定),留待下一轮单独设计落地。\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | shadow 样本/准确率/critical misroute 门槛;active 后 SLO regression 自动 rollback;全局 kill switch |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "91714631c95f8bee4253ca9398d3ed9bab2b925f": {"t": 1785759364.714228, "n": 156548, "h": "265fdf46525968f2", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.2.3 同订阅多 Key:能力共享与配额负载均衡\n\n**问题**:官方订阅 plan(Claude Max、Codex plan 等)允许用户持有多个 key,或购买多份相同套餐。若每个 endpoint 独立探测与配置,探测预算被浪费、配置重复;请求量大时还需要在同 plan 的多个 key 之间按官方 RPM 限速自动负载均衡,而不是打满第一个 key 才溢出到下一个。\n\n**能力检测去重(同一套餐只需一套能力检测和配置)**:\n\n```text\nSubscriptionUID 相同的 endpoint 共享一份「订阅级能力画像」:\n 模型列表 / vision / tool / reasoning 支持 / 协议兼容开关 → 只探测一次,\n 存在 SubscriptionProfile.SharedCapability,endpoint 画像以引用方式继承。\n\n仍然 per-endpoint 维护的:健康状态、延迟、熔断、用量窗口\n (key 可能被单独封禁/限速,能力相同不代表运行态相同)\n\n继承失效条件:某 key 实测模型列表哈希与订阅级不一致(GroupChangeDetector 信号)\n → 该 key 脱离继承、独立探测,并在 UI 标记「与套餐能力不一致」警告。\n```\n\n**配额负载均衡(Phase 1 shadow 展示,Phase 2 生效)**:\n\n```text\n每 key 维护配额余量视图:\n remainingRPM = officialRPM − 当前窗口已用\n officialRPM 来源优先级:内置 manifest > 用户配置 > RateLimitDiscoverer 建议\n\n同订阅多 key 时按「剩余余量加权」分配请求(余量大者优先、\n窗口最早重置者兜底),替代顺序打满:\n 仅当渠道 autoManaged 且同一 SubscriptionUID 下 key 数 > 1 时启用;\n 否则保持现有 sequential/random key 顺序策略不变。\n\n某 key 的窗口配额耗尽 → 临时降权并切到同订阅其他 key,\n不熔断整个渠道(这是配额耗尽,不是故障)。\n```\n\n### 3.2.4 订阅用量窗口 (Usage Windows)\n\n部分官方 plan 提供 5h / 日 / 周 / 月等窗口的用量查询与限制(如 Claude 订阅的 5h 滚动窗口 + 周配额)。在 key 的位置展示这些窗口,帮助用户掌握使用情况:\n\n```go\ntype UsageWindow struct {\n Window string `json:\"window\"` // \"5h\" | \"day\" | \"week\" | \"month\"\n Used float64 `json:\"used\"` // 已用量\n Limit float64 `json:\"limit\"` // 上限,0 = 未知\n Unit string `json:\"unit\"` // requests | tokens | credits | percent\n ResetsAt time.Time `json:\"resetsAt\"` // 窗口重置时间\n Source string `json:\"source\"` // official_api | response_header | local_metering\n FetchedAt time.Time `json:\"fetchedAt\"`\n}\n// KeyEndpointProfile 与 SubscriptionProfile 均增加 UsageWindows []UsageWindow\n```\n\n数据来源优先级:\n\n```text\n1. 官方用量 API:有 provider adapter 的 plan 主动拉取(§Phase 4 订阅 adapter 的可提前子集)\n2. 响应头:anthropic-ratelimit-* / x-ratelimit-* 等(复用 RateLimitDiscoverer 的信号 tap)\n3. 本地计量兜底:CCX 按 key 累计请求数/token(误差来自窗口起点对齐,标注 Source=local_metering)\n```\n\nUI 要求:key 行内显示主窗口(5h)用量进度条,悬浮展开全部窗口;用量 >80% 黄色预警、超限红色并显示预计重置时间。调度联动(Phase 2):窗口余量进入配额负载均衡权重,窗口耗尽的 key 临时降权而非熔断。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n#### 4.2.1 内置模型清单 (Builtin Models Manifest)\n\n**问题**:部分官方订阅 plan 的入口没有正确的 models 接口——要么不存在(404)、要么需要额外权限、要么返回与订阅实际可用模型不符的列表(典型:Claude 订阅 OAuth 入口、Codex plan 入口、部分官方企业网关)。对这些 baseURL 做模型发现会失败或产出错误画像。\n\n**方案**:CCX 内置「已知官方 baseURL → 模型清单」映射表,命中时直接返回内置内容,完全跳过上游 models 接口:\n\n```go\n// backend-go/internal/config/builtin_models_manifest.go(生成式,随模型注册表更新)\ntype BuiltinModelsManifest struct {\n BaseURLPattern string `json:\"baseUrlPattern\"` // 精确 host 或前缀匹配,如 \"api.anthropic.com\"\n ServiceType string `json:\"serviceType\"` // messages/responses/…\n PlanHint string `json:\"planHint,omitempty\"` // 订阅类型提示(如 \"claude_subscription\")\n ModelIDs []string `json:\"modelIds\"` // 该入口实际可用的模型清单\n DisableProbe bool `json:\"disableProbe\"` // true 时 Discovery 跳过 GET /v1/models\n}\n```\n\n**两个生效位置**:\n\n```text\n1. Discovery(§4.2 流程第 2 步之前):\n baseURL 命中 manifest 且 DisableProbe=true\n → 跳过 GET /v1/models,直接用 ModelIDs 生成 ModelProfile[]\n → ProfileSource = \"builtin_manifest\",不再因 models 接口失败而误判 misconfigured\n\n2. /v1/models 代理响应(现有 Models API):\n 客户端向 CCX 请求 /v1/models 时,聚合结果中该渠道的贡献\n 直接取内置清单,不向上游发起 models 请求\n (渠道显式配置了 supportedModels 时仍以用户配置优先)\n```\n\n**维护与覆盖顺序**:\n\n```text\n渠道显式 supportedModels(用户手动) > 内置 manifest > 上游 models 接口实探 > 内置候选模型回退\n```\n\nmanifest 与模型注册表同源生成(`generated_model_registry.go` 同级),新模型发布时随注册表一起更新;HealthAnalyzer 对命中 manifest 的 endpoint 不将「models 接口 404/403」计入 misconfigured 信号。\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法细分时进入 `general`;有规范 Knowledge 基准则使用其软评分,缺少直接证据时仍取 0.5 中性值。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n**手动 L3 第一版状态(2026-07-15)**:已实现 `pq-v1-20260715` 固定 JSON canary,覆盖\nMessages、Chat Completions、Responses 与 Gemini 上游协议。调用方只能指定 `endpointUid`、\n`modelId` 和 1~3 次重复数,不能注入 prompt。评分仍按 40% 完整性、30% 语义正确性、\n15% 严格 JSON 格式、15% 延迟执行;第一版使用非流式完整响应延迟作为首 token 延迟代理,\n后续引入流式采样时再替换该维度。单次有效样本 confidence=0.6,最多三次提升到 0.8;\n失败样本按 0 分计入均值,全部失败不写画像。\n\n安全边界:仅管理 API 手动触发,不启动自动 worker;当前进程按 UTC 日设置 12 次硬预算,\n请求前原子预留,单次 90 秒超时、响应体上限 64 KiB。同一 endpoint×model 不并发执行;\n单次请求最多允许 2048 个输出 token,以容纳推理模型的隐藏思考并限制最坏额度消耗。\n对声明可控推理的模型固定采用最低可用 effort;Claude 兼容端点若只暴露\n`reasoning_content` 而未声明 thinking 模式,则与 capability-test 一致显式关闭 thinking。\n原始模型输出和明文 Key 既不落盘也不返回,只保存分数、canary 版本、置信度和脱敏判定。\n`user_feedback` 仍高于 `probe`,自动发现重建 ModelProfile 时必须保留既有质量证据。\n跨进程共享预算与 LLM-as-judge/语义向量评分仍属于后续阶段。\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设 + 自定义\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n| `custom` | 用户自定义 | 用户自定义 | 高级用户微调,范围 0.0 ~ 3.0 |\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法细分的通用任务;缺少基准证据时中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general;有规范 Knowledge 基准时使用其软评分,缺少直接证据时取 0.5 中性。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? canonicalBenchmark[model][domain] * providerQualityFactor\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 规范模型基准 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n#### 5.7.4 规范模型能力基准\n\n`shared/model-registry/ccx_model_registry.json` 的 `benchmarkProfiles` 独立保存规范模型能力上界,不与 `upstreamCapabilities` 混合。后者仍是 context、vision、tool calls 等硬能力的事实源;benchmark 只参与 Autopilot 软评分和 trace 解释,绝不改变 `supportedModels` 或 CapabilityFloor。\n\n```text\nEffectiveCapability[domain]\n = CanonicalCeiling[model, domain]\n × ProviderQualityFactor[endpoint]\n\nProviderQualityFactor = 1 - confidence × (1 - providerQualityScore)\n无渠道质量证据或 confidence<0.5 时 factor=1;可信证据只能从规范模型上界向下折算。\n```\n\n基准数据必须保留领域向量、来源、核验日期、lane、共享结果数和可比类别覆盖率,不能只保存站点 Overall。当前 BenchLM 接入为 `provisional`:Claude Opus 4.8、GPT-5.6 Terra、GPT-5.6 Sol 各有 30 个共享结果,8 类中 5 类可比,核验日期为 2026-07-13。\n\n领域映射:Knowledge → `general`,Math → `reasoning`,Coding → `coding` / `code_review`,Agentic → `agentic`,Multimodal → `aesthetics_ui`(仅弱代理,解释置信度减半)。Writing / Translation 没有直接类别证据时继续回退家族种子或 0.5。BenchLM 的 cohort 归一化、缺失项插补和外部校准决定了 Overall 不是绝对能力值,因此路由只消费可映射的原始类别分。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选最佳匹配\n // 匹配优先级:\n // a. 同模型族(opus→opus, sonnet→sonnet)—— 最高优先\n // b. 同质量档(premium→premium)\n // c. 上下文窗口最接近(不超也不差太多)\n // d. 探测延迟最低\n best := rankBySimilarity(eligible, requestModel, floor)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\nGET /api/health-center/provider-quality/budget → 查看本进程手动 L3 每日预算\nPOST /api/health-center/provider-quality/probe → 对指定 endpoint×model 执行固定质量 canary\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n#### 8.0.1 实现现状与演进方向(2026-07-09 核实)\n\n四界面在 **Web 前端(`frontend/src`)已大面积落地**,不是纯文档:\n\n| 界面 | Web 实现 | Desktop 实现 |\n|------|----------|--------------|\n| 健康中心 | ✅ `HealthCenterView.vue` + `HealthCenterStats`/`HealthChannelTable`/`HealthChannelDetail`/`ProfileChangelogTimeline`,路由 `/health` | ❌ 无 |\n| 订阅中心 | ✅ `SubscriptionsView.vue` + `SubscriptionPlanTable`,CRUD + 刷新余额,路由 `/subscriptions` | ⚠️ 独立旧实现 `subscriptions/SubscriptionTab.vue`(与 Web 两套代码) |\n| 渠道中心 | ✅ `ChannelOrchestration.vue`(六态 `ChannelHealthBadge`、来源 chips、`autoManaged` 图标齐备) | ❌ 未同步 |\n| 管理面板 | ⚠️ `AutopilotModePanel.vue` 仅暴露 mode/killSwitch/costPreference | ❌ 无 |\n| 驾驶舱 | ✅ `CockpitView.vue`(观测型总览:健康统计+余额汇总+渠道推荐) | ⚠️ `cockpit` tab 渲染的是会话 Dashboard,职责不同 |\n\n**与本设计意图的关系**(对齐用户「自动调度为主、废旧手动驾驶舱」的诉求):\n- **旧的「手动调度驾驶舱」在 Web 端已不存在**。`CockpitView` 已是 §8.7 定义的观测型总览,只回答健康/省钱/风险/待办四问题,不做渠道配置编辑,符合「程序自动选、人只观测+有限干预」的方向。无需再废除,只需继续收敛为纯观测。\n- **统一提交入口已存在**(§8.4 快速添加 + §8.5 订阅中心),new-api 集成(§8.5.1)在此之上把「填 baseURL+key」进一步自动化。\n\n**前端演进待办**(本设计新增/明确):\n1. **补 `ManualRoutingIntent` 前端**:这是用户要的「有限偏向性干预」,后端已有、两个前端均未实现(§8.3 试用意图入口尚缺)。\n2. **Desktop 前端对齐 Web**:健康中心/渠道自动托管/管理面板/观测型驾驶舱在 Desktop 几乎空白,需同步或复用 Web 组件,避免长期维护两套。\n3. **管理面板补字段**:暴露 `l2ProbeEnabled`、探测预算、new-api/多来源订阅 adapter 开关(§8.5.2)等,当前只有 3 个字段。\n4. **订阅中心接 §8.5.1/§8.5.2**:新增「new-api」订阅类型与 Provider Adapter 能力矩阵驱动的动态表单。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.5.1 new-api 订阅集成(统一账号接入)\n\n**目标**:用户在订阅中心手填一个 new-api(及其 fork)账号的「系统访问令牌」即可,CCX 自动用该令牌创建代理专用 key、拉取分组倍率、查询额度余额,并把「站点 baseURL + 新建 key」自动落成一个上游渠道,交给 Autopilot 探测与调度。\n\n**范围约束**:\n- 只使用 new-api 用户在「个人设置」生成的 **系统访问令牌(access_token)** 一种凭据;令牌用于 API 调用的身份验证,属敏感数据,须妥善加密存储。\n- **不做** cookie 登录 + 2FA + 导出后台渠道明文 key 的管理员迁移路径(all-api-hub 的 newApiSession 能力),本设计不涉及。\n- new-api 归入 `OriginType=relay`、`OriginTier=second`(中转站),信任等级不因自动化提升;实时质量仍由运行画像决定。\n- 认证令牌像其他订阅凭据一样由用户手动录入,CCX 不代替用户在 new-api 侧注册或登录。\n\n**认证与凭据模型**:\n\nnew-api family 的管理接口鉴权(依据 all-api-hub 传输层实现):\n\n```text\nAuthorization: Bearer # 系统访问令牌;raw 模式可去掉 Bearer 前缀\nNew-API-User: # 当前用户 ID(必带)\nUser-id: # fork 兼容回退(Veloera/voapi/Super-API 等)\nContent-Type: application/json\n```\n\n统一响应信封:`{ \"success\": bool, \"data\": T, \"message\": string }`;解析时先判 `success`,再取 `data`。\n\n**SubscriptionProfile 扩展**:\n\n在 §3.2.2 的 `SubscriptionProfile` 基础上,为可自动接入的订阅新增 provider 集成字段(沿用「默认关闭 + 显式 opt-in」守则):\n\n```go\n// SubscriptionProfile 新增字段(复用 Phase 4 Item 6 的 SubscriptionAutoRefresh 开关)\ntype SubscriptionIntegration struct {\n Kind string `json:\"kind\"` // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | ...\n BaseURL string `json:\"baseUrl\"` // new-api 站点地址(同时作为新建渠道的上游 baseURL)\n AccessToken string `json:\"-\"` // 系统访问令牌;加密存储,不出 API 响应\n UserID string `json:\"userId\"` // New-API-User 头所需\n AuthTokenMode string `json:\"authTokenMode,omitempty\"` // \"bearer\"(默认) | \"raw\"\n // 自动建 key 的模板\n ProvisionKeyName string `json:\"provisionKeyName,omitempty\"` // 默认 \"ccx-autopilot\"\n ProvisionGroup string `json:\"provisionGroup,omitempty\"` // 建 key 时指定分组,空=默认分组\n ProvisionModels []string `json:\"provisionModels,omitempty\"` // model_limits,空=不限制\n // 自动同步产物(回填,只读展示)\n ProvisionedTokenID int `json:\"provisionedTokenId,omitempty\"`\n LinkedChannelUID string `json:\"linkedChannelUid,omitempty\"`\n}\n```\n\n`AccessToken` 必须与现有敏感字段一样走加密存储,序列化到前端时脱敏(仅显示尾部若干位)。\n\n**new-api 接口清单**(本集成实际调用的子集,路径以主线 new-api 为准,字段名保持与上游一致):\n\n| 用途 | 方法 | 路径 | 请求体 | 响应关键字段 |\n|------|------|------|--------|--------------|\n| 校验令牌 + 查用户/余额 | GET | `/api/user/self` | — | `data.id`、`data.username`、`data.quota`(剩余额度)、`data.used_quota` |\n| 拉分组倍率 | GET | `/api/user/self/groups` | — | `data: { : { desc, ratio } }` |\n| 站点全部分组 | GET | `/api/group` | — | `data: string[]` |\n| 账号可用模型 | GET | `/api/user/models` | — | `data: string[]` |\n| 创建代理 key | POST | `/api/token/` | `CreateTokenRequest`(见下) | `data.key` / `success` |\n| key 列表 | GET | `/api/token/?p={p}&size={n}` | — | `data.items[]`(含 `id/key/name/status/remain_quota`) |\n| 更新 key | PUT | `/api/token/` | `{ ...token, id }` | `success` |\n| 删除 key | DELETE | `/api/token/{id}` | — | `success` |\n\n`CreateTokenRequest`:\n\n```json\n{\n \"name\": \"ccx-autopilot\",\n \"remain_quota\": 0,\n \"expired_time\": -1,\n \"unlimited_quota\": true,\n \"model_limits_enabled\": false,\n \"model_limits\": \"\",\n \"allow_ips\": \"\",\n \"group\": \"\"\n}\n```\n\n`quota` 是 new-api 内部额度单位(非美元),换算比例读 `GET /api/status` 的 `price` / `PaymentUSDRate`(无鉴权),或允许用户在订阅中手填换算率;MVP 直接以 quota 原值展示 + 站点换算率估算余额。\n\n**接入流程**(订阅中心「新增订阅 → 类型选 new-api」):\n\n```text\n1. 用户输入:站点 baseURL + 系统访问令牌(access_token) + userId\n (userId 可选自动获取:先用令牌调 /api/user/self 拿 data.id 回填)\n2. 校验:GET /api/user/self\n ├─ success=false / 401 → 令牌无效或 userId 不匹配,报错停止\n └─ 成功 → 记录 username、quota,创建 SubscriptionProfile(provider=new_api, originTier=second)\n3. 拉分组:GET /api/user/self/groups → 写入 GroupMultipliers({group: ratio})\n GET /api/user/models → 记录账号可用模型,供渠道 supportedModels 参考\n4. 建 key:POST /api/token/(按 ProvisionKeyName/Group/Models 模板)\n ├─ 已存在同名 ccx-autopilot key → 复用其 id,不重复创建(列表比对 name)\n └─ 新建成功 → 回填 ProvisionedTokenID,取 data.key 作为渠道 apiKey\n5. 自动建渠道:用 baseURL + 新建 key 创建一个上游渠道\n ├─ channelKind 由用户选择或按站点探测(messages/chat/responses/gemini)\n ├─ status=unknown,autoManaged=true,OriginType=relay\n ├─ 建立 SubscriptionUID → ChannelUID 链接(回填 LinkedChannelUID)\n └─ 触发 §8.4 的 Discovery + 能力测试标准流程\n6. 完成:订阅卡片显示余额、分组倍率、已绑定渠道、探测进度\n```\n\n关键点:new-api 集成**不额外造探测逻辑**,第 5 步之后完全复用现有 §8.4「添加并探测」链路;集成层只负责「令牌 → key + 分组 + 余额 + baseURL」的自动填充,把手填 baseURL/key 的动作自动化掉。\n\n**刷新与同步**(复用 `POST /api/subscriptions/{uid}/refresh`):\n\n| 触发 | 动作 |\n|------|------|\n| 手动刷新余额 | `GET /api/user/self` 更新 `Balance` + `BalanceUpdatedAt` |\n| 定时刷新(可选,opt-in) | 复用 Phase 4 Item 6 `SubscriptionAutoRefresh`;new-api 作为 `relay` provider adapter 纳入,默认关闭 |\n| 分组倍率变更 | 刷新时对比 `/api/user/self/groups`,`GroupMultipliers` 变化 → 更新并提示「套餐倍率已变」 |\n| key 失效检测 | 渠道侧探测到该 key 全 401 → 订阅卡片提示「key 可能已删除/禁用」,提供「重新建 key」一键动作(重跑第 4-5 步,复用旧渠道,只换 apiKey) |\n| 可用模型变更 | `/api/user/models` 结果哈希变化 → 触发对应渠道重新 Discovery |\n\n**安全与边界**:\n- `access_token` 权限等同 new-api 账户,须加密存储、脱敏展示、禁止写入日志;刷新失败连续 N 次自动停用该订阅的自动刷新,只保留手动。\n- 自动建 key 默认 `unlimited_quota=true` 但可由用户改为限额模板;建 key 是有副作用的写操作,须在接入前明确告知用户「将在 new-api 侧创建一个名为 ccx-autopilot 的令牌」。\n- new-api 是 `OriginTier=second`,**不得**用作隐私敏感的 routing advisor / classifier(遵守 §3.2.1 约束)。\n- fork 兼容:请求同时带 `New-API-User` 与 `User-id` 头;响应信封字段名(`success/data/message`)在部分 fork 可能不同,adapter 需容错解析,失败时回退手动录入。\n- 一个 new-api 订阅默认只自动建/绑定一个渠道;用户如需该站多协议渠道,可在同订阅下手动增建,共用同一 key。\n\n### 8.5.2 订阅 Provider Adapter 框架(多来源余额与接入)\n\n§8.5.1 的 new-api 集成不是特例,而是「订阅 Provider Adapter」框架的一个实现。Phase 4 Item 6 已落地 openai/anthropic/google 的余额刷新 adapter,本框架把它抽象成统一接口,让 new-api 及后续来源(GitHub Copilot、Codex、Nvidia、MiniMax、DeepSeek 等)按相同契约接入,避免每加一个来源就散落一套逻辑(DRY)。\n\n**统一接口**:\n\n```go\n// backend-go/internal/autopilot/subscription/adapter.go\ntype ProviderAdapter interface {\n Kind() string // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | \"copilot\" | \"deepseek\" | ...\n\n // 校验凭据,返回账号身份(用户名/账户标识);失败即凭据无效\n Verify(ctx context.Context, cred Credential) (AccountInfo, error)\n // 查余额/额度;不支持返回 ErrUnsupported,由 UI 降级为手动录入\n FetchBalance(ctx context.Context, cred Credential) (Balance, error)\n // 拉分组倍率;仅 new-api 类中转有,官方直连返回 ErrUnsupported\n FetchGroups(ctx context.Context, cred Credential) (map[string]GroupRatio, error)\n // 自动创建代理 key;官方直连通常不支持(key 即用户 API key),返回 ErrUnsupported\n ProvisionKey(ctx context.Context, cred Credential, tmpl KeyTemplate) (ProvisionedKey, error)\n // 可用模型列表;用于渠道 supportedModels 参考\n FetchModels(ctx context.Context, cred Credential) ([]string, error)\n}\n```\n\n每个能力独立、可选:adapter 只实现自己支持的部分,不支持的统一返回 `ErrUnsupported`(接口隔离原则),UI 据此隐藏/降级对应入口。所有 adapter 共用 `SubscriptionAutoRefresh` 的 opt-in 开关与 `Validate()` 兜底,与 Phase 4 现有防御模式一致。\n\n**能力矩阵**(✅ 支持 / ⚠️ 部分或需确认 / ❌ 不支持,走手动):\n\n| 来源 | Kind | 验证 | 查余额 | 拉分组 | 自动建 key | OriginTier |\n|------|------|:----:|:------:|:------:|:----------:|------------|\n| new-api family | `new_api` | ✅ | ✅ quota | ✅ | ✅ | second |\n| OpenAI 官方 | `openai` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Anthropic 官方 | `anthropic` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Google 官方 | `google` | ✅ | ⚠️ | ❌ | ❌ | first |\n| GitHub Copilot | `copilot` | ⚠️ token | ❌ 无额度概念 | ❌ | ❌ | first |\n| Codex plan | `codex` | ⚠️ | ⚠️ 用量窗口 | ❌ | ❌ | first |\n| Nvidia NIM | `nvidia` | ✅ | ⚠️ credits | ❌ | ❌ | first |\n| MiniMax | `minimax` | ✅ | ⚠️ | ❌ | ❌ | first |\n| DeepSeek | `deepseek` | ✅ | ✅ balance API | ❌ | ❌ | first |\n\n说明:官方直连来源普遍**不支持自动建 key**(用户的 API key 本身就是凭据,直接作为渠道 apiKey 录入即可)、也**没有分组倍率**(价格按官方定价表)。它们的 adapter 价值集中在「验证 + 查余额/用量窗口」,把 §3.2.4 UsageWindow 的官方用量 API 来源坐实。DeepSeek 有独立余额查询端点,可完整支持余额。Copilot 属订阅制、无 token 余额概念,只做验证 + 用量提示。\n\n**接入分层**(YAGNI:不强求一次做全,按价值排期):\n\n```text\nP0(本轮):new_api(完整:验证+余额+分组+建key+自动建渠道)\n —— 覆盖用户最主要的中转站场景\nP1:deepseek(验证+余额)、官方三家余额已在 Phase 4 完成 → 补 UsageWindow 对齐\nP2:nvidia / minimax(验证+credits/余额)\nP3:copilot / codex(验证+用量窗口提示,无余额)\n```\n\n每个来源的具体端点在实现时于 `adapter.go` 各 provider 文件内落地,本设计只固定接口契约与能力矩阵,避免文档与上游 API 变动强耦合。新增来源 = 实现 `ProviderAdapter` + 在能力矩阵登记,不改框架。\n\n**能力缺失与降级**:\n- adapter 任一能力返回 `ErrUnsupported` → 订阅中心对应字段回退为手动录入(余额手填、倍率手填),不报错、不阻塞接入。\n- adapter 验证失败/超时 → 订阅可仍以「手动模式」创建,仅失去自动刷新与自动建 key,其余流程(手填 baseURL+key 建渠道)不受影响。\n- 官方来源无「自动建 key」时,接入流程退化为 §8.4 标准快速添加:用户填 API key,adapter 只补验证与余额。\n- 所有自动刷新遵守 `SubscriptionAutoRefresh` 默认关闭;连续失败自动停用自动刷新并在驾驶舱「人工待办」提示。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**Phase 3A 状态(2026-07-09)**:低风险、不改真实路由语义的子集已完成并接入真实链路——`Manager.collectAll()` 新旧画像对比产出变更事件(`DetectProfileChanges`)、`ProfileChangelogStore`(环形内存 + SQLite,30 天机会性清理)、`EventHub` 内存 pub/sub、`GET /api/health-center/changelog`(历史)+ `GET /api/health-center/events`(WebSocket 实时推送)、`AutoDiscoveryRunner` 发布 `discovery_completed`/`auto_mapping_applied`、前端 `ProfileChangelogTimeline.vue` 接入健康中心页并支持断线自动重连。均为只读展示,未修改 `SmartRouter`/`EndpointAttemptPolicy` 调度逻辑。落地时顺带发现并修复一个真实缺口:浏览器原生 WebSocket 无法设自定义 header,为此给 `middleware.getAPIKey` 增加了 `Sec-WebSocket-Protocol` 鉴权回退(业界标准做法,仅新增一个 key 来源,不降低现有校验)。\n\n**Phase 3B(已全部完成,2026-07-09)**:模型自动映射、自动恢复探测、晋升/降级机制会直接改变真实调度决策,风险明显高于 3A,四个子项已分批全部完成。§12.2 P1.5 的 SQLite schema version / 版本化 migration / `OriginType`&`OriginTier` 旧配置 backfill / `killSwitch`+按 task class/channel disable **已于 2026-07-09 补强完成**(详见 §12.2 P1.5)。**Phase 3B-1(自动恢复探测补全)已于 2026-07-09 完成**:修复了 L2 探测子系统里此前从未打通的两个真实 bug——① `collectAll()` 每轮把新构造的画像整行 Upsert,会把 `ProbeWorker` 刚写入的 `LastProbeAt`/`ProbeSuccess`/`ConsecutiveProbeSuccess` 等字段清零,导致 6 小时探测冷却期形同虚设(修复:`carryForwardProbeFields` 在 Upsert 前搬运 Probe* 字段,不影响 L1 诊断字段的现有覆盖逻辑);② `extractRawAPIKey` 是返回 `KeyMask` 掩码值的占位 stub,探测请求实际带的是假 key(修复:新增 `APIKeyResolver` 回调 + `Manager.ResolveAPIKey`,通过遍历配置里对应渠道的 `APIKeys` 逐个计算 `KeyHashFromAPIKey` 比对来还原明文;resolver 未注入或未命中时 fail-open 跳过探测且不消耗预算)。同时补齐了 `degraded/limited → healthy` 的连续成功恢复路径(新增 `KeyEndpointProfile.ConsecutiveProbeSuccess` 计数器 + `HealthCheckConfig.ProbeRecoveryThreshold`,默认连续 2 次探测成功才恢复,避免单次探测噪声导致状态抖动;探测失败计数器清零)。**Phase 3B-3(StabilityTier 晋降级滞后)已于 2026-07-09 完成**(提交 664d3fff):在原本每轮从瞬时 1h 窗口重算的无记忆 `StabilityTier` 上加一层连续窗口滞后——新增 `KeyEndpointProfile.EffectiveStabilityTier`/`StabilityPendingTier`/`StabilityPendingStreak` 三字段 + 纯函数 `applyStabilityHysteresis`(对称阈值:连续 N 轮同值才切换生效档,`HealthCheckConfig.StabilityHysteresisWindows` 默认 2;首次画像直接采用,不等待阈值,避免新渠道卡在零值),`AggregateChannelProfile` 改读 `EffectiveStabilityTier`(空则回退 `StabilityTier`)。晋升/降级机制的数据模型仅限于 StabilityTier,不改变 QualityTier/SpeedTier/CostTier 语义。**Phase 3B-2(模型自动映射闭环)已于 2026-07-09 完成**(提交 0f02e988,随后修复一处 critical 集成 bug,提交 856372c4):新建 `ModelProfileStore`(SQLite,`model_profiles` 表)+ 扩展 `AutoDiscoveryRunner` 为每个发现模型写入 `ModelProfile`(仅 `AutoManaged` 渠道);新建 `ModelResolver` 实现 §5.4 五步逻辑(手动 `RedirectModel` 短路 → `GetModelProfiles` → `filterByCapabilityFloor` → `rankBySimilarity`);`scheduler` 包新增 `ModelSupportResolverFunc` hook(fail-open,未注入或返回不支持时严格回退到原 `ExplainModelSupport`);`endpoint_policy.go` 的 `resolveMappedModel`(此前是永不触发的死代码——`profile.ModelMapping` 从未被写入)现调用 `ModelResolver`;`handlers/common` 在 `RedirectModel` 之后应用 `MappedModel` 覆盖请求体,并按 `EchoMappedModel` 配置回显 `X-CCX-Mapped-Model`/`X-CCX-Original-Model`/`X-CCX-Mapping-Source` 响应头。三条件安全门控:`ModelMapping.AutoResolve==true`(**默认由死配置的 true 改为 false**)AND `RoutingMode ∈ {assist, auto}` AND 无 `KillSwitch`,三者任一不满足则所有新增代码路径行为与今天字节级一致;且仅 `AutoManaged` 渠道触发,手动配置渠道完全不受影响。对抗性复核发现并修复一个真实 critical bug:`scoreEndpointForKey` 用空 `channelUID` 计算 `EndpointUID` 并写入 `modelByUID`,而 `handlers` 层用真实 `channelUID` 查 `modelByUID`,两者哈希永不相等导致 `MappedModel` 在 key 级调度路径上完全查不到(提交 856372c4 修复:命中画像后改用 `profile.EndpointUID`,新增回归测试锁定该不变量)。至此 Phase 3B 四个子项(自动恢复探测、StabilityTier 滞后、模型自动映射、active filter 前置判定)全部完成;SLO regression 自动回滚仍是独立子系统,留待后续单独立项。\n\n**范围**:\n- [x] 运行时指标驱动画像实时更新(Phase 3A:`collectAll()` 每轮 diff + changelog + WebSocket 推送)\n- [x] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)—— Phase 3B-2(2026-07-09 完成,提交 0f02e988 + 856372c4)\n- [x] active model filter / context filter 支持自动映射前置判定 —— Phase 3B-2:`scheduler.ModelSupportResolverFunc` fail-open 接入 `select.go`\n- [x] 自动恢复探测(limited/dead → healthy)—— Phase 3B-1(2026-07-09 完成):`APIKeyResolver` + `carryForwardProbeFields` + `ConsecutiveProbeSuccess`/`ProbeRecoveryThreshold`\n- [x] 晋升/降级机制(连续成功→升级,连续失败→降级)—— Phase 3B-3(2026-07-09 完成,提交 664d3fff):`StabilityTier` 连续窗口滞后(`EffectiveStabilityTier`),暂不含 QualityTier/SpeedTier/CostTier\n- [x] WebSocket 推送画像变更事件(Phase 3A:`GET /api/health-center/events`)\n- [x] 前端画像变更历史/时间线(Phase 3A:`ProfileChangelogTimeline.vue`)\n\n**预估工期**:2-3 周(3A、3B-1、3B-2、3B-3 均已完成;SLO regression 自动回滚待单独立项评估工期)\n\n### Phase 4:高级特性\n\n**Phase 4 状态(2026-07-09,全部完成)**:8 项全部实现并接入真实链路,均遵循\"默认关闭 + 显式 opt-in\"安全守则。\n\n**范围**:\n- [x] 多维度标签系统扩展(用户自定义标签)——Item 1\n- [x] 成本报表:按用户/模型/渠道/key 统计真实有效成本——Item 2(新增 `proxyKeyMask` 身份贯穿链路:`middleware.GetProxyKeyMask` → `ChannelLog`/`RequestRecord` → SQLite `proxy_key_mask` 列,供 Item 4 复用)\n- [x] SLO regression 自动回滚——Item 3(默认关闭;`SLORollbackConfig`,连续 degrading 窗口数默认 3,不做自动恢复以避免震荡)\n- [x] 渠道推荐:根据使用模式推荐新渠道——Item 4\n- [x] 批量渠道管理(导入/导出/模板)——Item 5\n- [x] 订阅中心 provider adapter:可选自动刷新余额/套餐状态——Item 6(默认关闭;仅 openai/anthropic/google 且显式填写 BillingAPIKey 的订阅生效,中转/公益渠道继续手动维护)\n- [x] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板——Item 7\n- [x] A/B 测试:低比例统计抽样双发——Item 8(默认关闭,默认 1% 抽样 + 每小时 60 次硬预算;主请求路径不受影响,影子请求在主响应返回后异步发起)\n\n**关键实现说明**:\n- Item 3/6/8 均在 `AutopilotRoutingConfig` 新增独立配置块(`SLORollback`/`SubscriptionAutoRefresh`/`ABTest`),`Validate()` 统一做负值/越界兜底,保持与既有字段一致的防御模式。\n- Item 4 直接复用 Item 2 的 `proxyKeyMask` 身份标记,未重新发明身份识别机制。\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- ~~SQLite schema version 和幂等 migration。~~ **已完成**:`ensureSchemaVersion` 基于 `PRAGMA user_version`,当前 7 张表定版为 v1 基线;库版本高于代码版本时返回 error(fail-closed,不做任何写操作),交给下游 fail-open 分支接管。\n- ~~旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。~~ **已完成**:`ensureOriginBackfill` 只补 `\"unknown\"`,不做任何基于 URL/名称的猜测推断;已接线到 `Profiler.DeriveEndpointProfile`,`BreakTieByOriginTier` 现在能吃到真实数据(此前一直是 fallback 的 `unknown`)。\n- ~~profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。~~ **已确认满足并测试锁定**:`main.go` 现有结构本就是 fail-open(`NewProfileStore`/`NewManager` 出错只打日志、`autopilotManager` 保持 `nil`,下游全部有 `nil` 守卫),本次新增 `schema_migration_test.go` 用测试锁定这条不变量。\n- ~~全局 `killSwitch`、按 task class disable、按 channel disable。~~ **已完成**:新增 `AutopilotRoutingConfig.DisabledTaskClasses`/`DisabledChannelUIDs`;`CandidateFilterFor`/`executeFilter`/`BuildPlan` 三处一致接入,`smart_router_invariant_test.go` 覆盖\"task class 禁用后回退默认调度\"\"channel 禁用后不出现在候选里\"\"dry-run 与真实路径行为一致\"三条不变量。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。**待实现**:`TrustedRoutingAdvisor.promotionMode` 目前是 `manual`,shadow→candidate→active 已有人工审查关卡;自动回滚是独立的新子系统(需要基线捕获窗口 + 实时对比 + 阈值判定),留待下一轮单独设计落地。\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | shadow 样本/准确率/critical misroute 门槛;active 后 SLO regression 自动 rollback;全局 kill switch |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "808c426f1eec39764bda6b27b04fb06db40479f7": {"t": 1785759366.6274493, "n": 155652, "h": "abb2379d3525e3ea", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.2.3 同订阅多 Key:能力共享与配额负载均衡\n\n**问题**:官方订阅 plan(Claude Max、Codex plan 等)允许用户持有多个 key,或购买多份相同套餐。若每个 endpoint 独立探测与配置,探测预算被浪费、配置重复;请求量大时还需要在同 plan 的多个 key 之间按官方 RPM 限速自动负载均衡,而不是打满第一个 key 才溢出到下一个。\n\n**能力检测去重(同一套餐只需一套能力检测和配置)**:\n\n```text\nSubscriptionUID 相同的 endpoint 共享一份「订阅级能力画像」:\n 模型列表 / vision / tool / reasoning 支持 / 协议兼容开关 → 只探测一次,\n 存在 SubscriptionProfile.SharedCapability,endpoint 画像以引用方式继承。\n\n仍然 per-endpoint 维护的:健康状态、延迟、熔断、用量窗口\n (key 可能被单独封禁/限速,能力相同不代表运行态相同)\n\n继承失效条件:某 key 实测模型列表哈希与订阅级不一致(GroupChangeDetector 信号)\n → 该 key 脱离继承、独立探测,并在 UI 标记「与套餐能力不一致」警告。\n```\n\n**配额负载均衡(Phase 1 shadow 展示,Phase 2 生效)**:\n\n```text\n每 key 维护配额余量视图:\n remainingRPM = officialRPM − 当前窗口已用\n officialRPM 来源优先级:内置 manifest > 用户配置 > RateLimitDiscoverer 建议\n\n同订阅多 key 时按「剩余余量加权」分配请求(余量大者优先、\n窗口最早重置者兜底),替代顺序打满:\n 仅当渠道 autoManaged 且同一 SubscriptionUID 下 key 数 > 1 时启用;\n 否则保持现有 sequential/random key 顺序策略不变。\n\n某 key 的窗口配额耗尽 → 临时降权并切到同订阅其他 key,\n不熔断整个渠道(这是配额耗尽,不是故障)。\n```\n\n### 3.2.4 订阅用量窗口 (Usage Windows)\n\n部分官方 plan 提供 5h / 日 / 周 / 月等窗口的用量查询与限制(如 Claude 订阅的 5h 滚动窗口 + 周配额)。在 key 的位置展示这些窗口,帮助用户掌握使用情况:\n\n```go\ntype UsageWindow struct {\n Window string `json:\"window\"` // \"5h\" | \"day\" | \"week\" | \"month\"\n Used float64 `json:\"used\"` // 已用量\n Limit float64 `json:\"limit\"` // 上限,0 = 未知\n Unit string `json:\"unit\"` // requests | tokens | credits | percent\n ResetsAt time.Time `json:\"resetsAt\"` // 窗口重置时间\n Source string `json:\"source\"` // official_api | response_header | local_metering\n FetchedAt time.Time `json:\"fetchedAt\"`\n}\n// KeyEndpointProfile 与 SubscriptionProfile 均增加 UsageWindows []UsageWindow\n```\n\n数据来源优先级:\n\n```text\n1. 官方用量 API:有 provider adapter 的 plan 主动拉取(§Phase 4 订阅 adapter 的可提前子集)\n2. 响应头:anthropic-ratelimit-* / x-ratelimit-* 等(复用 RateLimitDiscoverer 的信号 tap)\n3. 本地计量兜底:CCX 按 key 累计请求数/token(误差来自窗口起点对齐,标注 Source=local_metering)\n```\n\nUI 要求:key 行内显示主窗口(5h)用量进度条,悬浮展开全部窗口;用量 >80% 黄色预警、超限红色并显示预计重置时间。调度联动(Phase 2):窗口余量进入配额负载均衡权重,窗口耗尽的 key 临时降权而非熔断。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n#### 4.2.1 内置模型清单 (Builtin Models Manifest)\n\n**问题**:部分官方订阅 plan 的入口没有正确的 models 接口——要么不存在(404)、要么需要额外权限、要么返回与订阅实际可用模型不符的列表(典型:Claude 订阅 OAuth 入口、Codex plan 入口、部分官方企业网关)。对这些 baseURL 做模型发现会失败或产出错误画像。\n\n**方案**:CCX 内置「已知官方 baseURL → 模型清单」映射表,命中时直接返回内置内容,完全跳过上游 models 接口:\n\n```go\n// backend-go/internal/config/builtin_models_manifest.go(生成式,随模型注册表更新)\ntype BuiltinModelsManifest struct {\n BaseURLPattern string `json:\"baseUrlPattern\"` // 精确 host 或前缀匹配,如 \"api.anthropic.com\"\n ServiceType string `json:\"serviceType\"` // messages/responses/…\n PlanHint string `json:\"planHint,omitempty\"` // 订阅类型提示(如 \"claude_subscription\")\n ModelIDs []string `json:\"modelIds\"` // 该入口实际可用的模型清单\n DisableProbe bool `json:\"disableProbe\"` // true 时 Discovery 跳过 GET /v1/models\n}\n```\n\n**两个生效位置**:\n\n```text\n1. Discovery(§4.2 流程第 2 步之前):\n baseURL 命中 manifest 且 DisableProbe=true\n → 跳过 GET /v1/models,直接用 ModelIDs 生成 ModelProfile[]\n → ProfileSource = \"builtin_manifest\",不再因 models 接口失败而误判 misconfigured\n\n2. /v1/models 代理响应(现有 Models API):\n 客户端向 CCX 请求 /v1/models 时,聚合结果中该渠道的贡献\n 直接取内置清单,不向上游发起 models 请求\n (渠道显式配置了 supportedModels 时仍以用户配置优先)\n```\n\n**维护与覆盖顺序**:\n\n```text\n渠道显式 supportedModels(用户手动) > 内置 manifest > 上游 models 接口实探 > 内置候选模型回退\n```\n\nmanifest 与模型注册表同源生成(`generated_model_registry.go` 同级),新模型发布时随注册表一起更新;HealthAnalyzer 对命中 manifest 的 endpoint 不将「models 接口 404/403」计入 misconfigured 信号。\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法细分时进入 `general`;有规范 Knowledge 基准则使用其软评分,缺少直接证据时仍取 0.5 中性值。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设 + 自定义\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n| `custom` | 用户自定义 | 用户自定义 | 高级用户微调,范围 0.0 ~ 3.0 |\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法细分的通用任务;缺少基准证据时中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general;有规范 Knowledge 基准时使用其软评分,缺少直接证据时取 0.5 中性。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? canonicalBenchmark[model][domain] * providerQualityFactor\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 规范模型基准 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n#### 5.7.4 规范模型能力基准\n\n`shared/model-registry/ccx_model_registry.json` 的 `benchmarkProfiles` 独立保存规范模型能力上界,不与 `upstreamCapabilities` 混合。后者仍是 context、vision、tool calls 等硬能力的事实源;benchmark 只参与 Autopilot 软评分和 trace 解释,绝不改变 `supportedModels` 或 CapabilityFloor。\n\n```text\nEffectiveCapability[domain]\n = CanonicalCeiling[model, domain]\n × ProviderQualityFactor[endpoint]\n\nProviderQualityFactor = 1 - confidence × (1 - providerQualityScore)\n无渠道质量证据或 confidence<0.5 时 factor=1;可信证据只能从规范模型上界向下折算。\n```\n\n基准数据必须保留领域向量、来源、核验日期、lane、共享结果数和可比类别覆盖率,不能只保存站点 Overall。当前 BenchLM 接入为 `provisional`:Claude Opus 4.8、GPT-5.6 Terra、GPT-5.6 Sol 各有 30 个共享结果,8 类中 5 类可比,核验日期为 2026-07-13。\n\n领域映射:Knowledge → `general`,Math → `reasoning`,Coding → `coding` / `code_review`,Agentic → `agentic`,Multimodal → `aesthetics_ui`(仅弱代理,解释置信度减半)。Writing / Translation 没有直接类别证据时继续回退家族种子或 0.5。BenchLM 的 cohort 归一化、缺失项插补和外部校准决定了 Overall 不是绝对能力值,因此路由只消费可映射的原始类别分。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选最佳匹配\n // 匹配优先级:\n // a. 同模型族(opus→opus, sonnet→sonnet)—— 最高优先\n // b. 同质量档(premium→premium)\n // c. 上下文窗口最接近(不超也不差太多)\n // d. 探测延迟最低\n best := rankBySimilarity(eligible, requestModel, floor)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n#### 8.0.1 实现现状与演进方向(2026-07-09 核实)\n\n四界面在 **Web 前端(`frontend/src`)已大面积落地**,不是纯文档:\n\n| 界面 | Web 实现 | Desktop 实现 |\n|------|----------|--------------|\n| 健康中心 | ✅ `HealthCenterView.vue` + `HealthCenterStats`/`HealthChannelTable`/`HealthChannelDetail`/`ProfileChangelogTimeline`,路由 `/health` | ❌ 无 |\n| 订阅中心 | ✅ `SubscriptionsView.vue` + `SubscriptionPlanTable`,CRUD + 刷新余额,路由 `/subscriptions` | ⚠️ 独立旧实现 `subscriptions/SubscriptionTab.vue`(与 Web 两套代码) |\n| 渠道中心 | ✅ `ChannelOrchestration.vue`(六态 `ChannelHealthBadge`、来源 chips、`autoManaged` 图标齐备) | ❌ 未同步 |\n| 管理面板 | ⚠️ `AutopilotModePanel.vue` 仅暴露 mode/killSwitch/costPreference | ❌ 无 |\n| 驾驶舱 | ✅ `CockpitView.vue`(观测型总览:健康统计+余额汇总+渠道推荐) | ⚠️ `cockpit` tab 渲染的是会话 Dashboard,职责不同 |\n\n**与本设计意图的关系**(对齐用户「自动调度为主、废旧手动驾驶舱」的诉求):\n- **旧的「手动调度驾驶舱」在 Web 端已不存在**。`CockpitView` 已是 §8.7 定义的观测型总览,只回答健康/省钱/风险/待办四问题,不做渠道配置编辑,符合「程序自动选、人只观测+有限干预」的方向。无需再废除,只需继续收敛为纯观测。\n- **统一提交入口已存在**(§8.4 快速添加 + §8.5 订阅中心),new-api 集成(§8.5.1)在此之上把「填 baseURL+key」进一步自动化。\n\n**前端演进待办**(本设计新增/明确):\n1. **补 `ManualRoutingIntent` 前端**:这是用户要的「有限偏向性干预」,后端已有、两个前端均未实现(§8.3 试用意图入口尚缺)。\n2. **Desktop 前端对齐 Web**:健康中心/渠道自动托管/管理面板/观测型驾驶舱在 Desktop 几乎空白,需同步或复用 Web 组件,避免长期维护两套。\n3. **管理面板补字段**:暴露 `l2ProbeEnabled`、探测预算、new-api/多来源订阅 adapter 开关(§8.5.2)等,当前只有 3 个字段。\n4. **订阅中心接 §8.5.1/§8.5.2**:新增「new-api」订阅类型与 Provider Adapter 能力矩阵驱动的动态表单。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.5.1 new-api 订阅集成(统一账号接入)\n\n**目标**:用户在订阅中心手填一个 new-api(及其 fork)账号的「系统访问令牌」即可,CCX 自动用该令牌创建代理专用 key、拉取分组倍率、查询额度余额,并把「站点 baseURL + 新建 key」自动落成一个上游渠道,交给 Autopilot 探测与调度。\n\n**范围约束**:\n- 只使用 new-api 用户在「个人设置」生成的 **系统访问令牌(access_token)** 一种凭据;令牌用于 API 调用的身份验证,属敏感数据,须妥善加密存储。\n- **不做** cookie 登录 + 2FA + 导出后台渠道明文 key 的管理员迁移路径(all-api-hub 的 newApiSession 能力),本设计不涉及。\n- new-api 归入 `OriginType=relay`、`OriginTier=second`(中转站),信任等级不因自动化提升;实时质量仍由运行画像决定。\n- 认证令牌像其他订阅凭据一样由用户手动录入,CCX 不代替用户在 new-api 侧注册或登录。\n\n**认证与凭据模型**:\n\nnew-api family 的管理接口鉴权(依据 all-api-hub 传输层实现):\n\n```text\nAuthorization: Bearer # 系统访问令牌;raw 模式可去掉 Bearer 前缀\nNew-API-User: # 当前用户 ID(必带)\nUser-id: # fork 兼容回退(Veloera/voapi/Super-API 等)\nContent-Type: application/json\n```\n\n统一响应信封:`{ \"success\": bool, \"data\": T, \"message\": string }`;解析时先判 `success`,再取 `data`。\n\n**SubscriptionProfile 扩展**:\n\n在 §3.2.2 的 `SubscriptionProfile` 基础上,为可自动接入的订阅新增 provider 集成字段(沿用「默认关闭 + 显式 opt-in」守则):\n\n```go\n// SubscriptionProfile 新增字段(复用 Phase 4 Item 6 的 SubscriptionAutoRefresh 开关)\ntype SubscriptionIntegration struct {\n Kind string `json:\"kind\"` // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | ...\n BaseURL string `json:\"baseUrl\"` // new-api 站点地址(同时作为新建渠道的上游 baseURL)\n AccessToken string `json:\"-\"` // 系统访问令牌;加密存储,不出 API 响应\n UserID string `json:\"userId\"` // New-API-User 头所需\n AuthTokenMode string `json:\"authTokenMode,omitempty\"` // \"bearer\"(默认) | \"raw\"\n // 自动建 key 的模板\n ProvisionKeyName string `json:\"provisionKeyName,omitempty\"` // 默认 \"ccx-autopilot\"\n ProvisionGroup string `json:\"provisionGroup,omitempty\"` // 建 key 时指定分组,空=默认分组\n ProvisionModels []string `json:\"provisionModels,omitempty\"` // model_limits,空=不限制\n // 自动同步产物(回填,只读展示)\n ProvisionedTokenID int `json:\"provisionedTokenId,omitempty\"`\n LinkedChannelUID string `json:\"linkedChannelUid,omitempty\"`\n}\n```\n\n`AccessToken` 必须与现有敏感字段一样走加密存储,序列化到前端时脱敏(仅显示尾部若干位)。\n\n**new-api 接口清单**(本集成实际调用的子集,路径以主线 new-api 为准,字段名保持与上游一致):\n\n| 用途 | 方法 | 路径 | 请求体 | 响应关键字段 |\n|------|------|------|--------|--------------|\n| 校验令牌 + 查用户/余额 | GET | `/api/user/self` | — | `data.id`、`data.username`、`data.quota`(剩余额度)、`data.used_quota` |\n| 拉分组倍率 | GET | `/api/user/self/groups` | — | `data: { : { desc, ratio } }` |\n| 站点全部分组 | GET | `/api/group` | — | `data: string[]` |\n| 账号可用模型 | GET | `/api/user/models` | — | `data: string[]` |\n| 创建代理 key | POST | `/api/token/` | `CreateTokenRequest`(见下) | `data.key` / `success` |\n| key 列表 | GET | `/api/token/?p={p}&size={n}` | — | `data.items[]`(含 `id/key/name/status/remain_quota`) |\n| 更新 key | PUT | `/api/token/` | `{ ...token, id }` | `success` |\n| 删除 key | DELETE | `/api/token/{id}` | — | `success` |\n\n`CreateTokenRequest`:\n\n```json\n{\n \"name\": \"ccx-autopilot\",\n \"remain_quota\": 0,\n \"expired_time\": -1,\n \"unlimited_quota\": true,\n \"model_limits_enabled\": false,\n \"model_limits\": \"\",\n \"allow_ips\": \"\",\n \"group\": \"\"\n}\n```\n\n`quota` 是 new-api 内部额度单位(非美元),换算比例读 `GET /api/status` 的 `price` / `PaymentUSDRate`(无鉴权),或允许用户在订阅中手填换算率;MVP 直接以 quota 原值展示 + 站点换算率估算余额。\n\n**接入流程**(订阅中心「新增订阅 → 类型选 new-api」):\n\n```text\n1. 用户输入:站点 baseURL + 系统访问令牌(access_token) + userId\n (userId 可选自动获取:先用令牌调 /api/user/self 拿 data.id 回填)\n2. 校验:GET /api/user/self\n ├─ success=false / 401 → 令牌无效或 userId 不匹配,报错停止\n └─ 成功 → 记录 username、quota,创建 SubscriptionProfile(provider=new_api, originTier=second)\n3. 拉分组:GET /api/user/self/groups → 写入 GroupMultipliers({group: ratio})\n GET /api/user/models → 记录账号可用模型,供渠道 supportedModels 参考\n4. 建 key:POST /api/token/(按 ProvisionKeyName/Group/Models 模板)\n ├─ 已存在同名 ccx-autopilot key → 复用其 id,不重复创建(列表比对 name)\n └─ 新建成功 → 回填 ProvisionedTokenID,取 data.key 作为渠道 apiKey\n5. 自动建渠道:用 baseURL + 新建 key 创建一个上游渠道\n ├─ channelKind 由用户选择或按站点探测(messages/chat/responses/gemini)\n ├─ status=unknown,autoManaged=true,OriginType=relay\n ├─ 建立 SubscriptionUID → ChannelUID 链接(回填 LinkedChannelUID)\n └─ 触发 §8.4 的 Discovery + 能力测试标准流程\n6. 完成:订阅卡片显示余额、分组倍率、已绑定渠道、探测进度\n```\n\n关键点:new-api 集成**不额外造探测逻辑**,第 5 步之后完全复用现有 §8.4「添加并探测」链路;集成层只负责「令牌 → key + 分组 + 余额 + baseURL」的自动填充,把手填 baseURL/key 的动作自动化掉。\n\n**刷新与同步**(复用 `POST /api/subscriptions/{uid}/refresh`):\n\n| 触发 | 动作 |\n|------|------|\n| 手动刷新余额 | `GET /api/user/self` 更新 `Balance` + `BalanceUpdatedAt` |\n| 定时刷新(可选,opt-in) | 复用 Phase 4 Item 6 `SubscriptionAutoRefresh`;new-api 作为 `relay` provider adapter 纳入,默认关闭 |\n| 分组倍率变更 | 刷新时对比 `/api/user/self/groups`,`GroupMultipliers` 变化 → 更新并提示「套餐倍率已变」 |\n| key 失效检测 | 渠道侧探测到该 key 全 401 → 订阅卡片提示「key 可能已删除/禁用」,提供「重新建 key」一键动作(重跑第 4-5 步,复用旧渠道,只换 apiKey) |\n| 可用模型变更 | `/api/user/models` 结果哈希变化 → 触发对应渠道重新 Discovery |\n\n**安全与边界**:\n- `access_token` 权限等同 new-api 账户,须加密存储、脱敏展示、禁止写入日志;刷新失败连续 N 次自动停用该订阅的自动刷新,只保留手动。\n- 自动建 key 默认 `unlimited_quota=true` 但可由用户改为限额模板;建 key 是有副作用的写操作,须在接入前明确告知用户「将在 new-api 侧创建一个名为 ccx-autopilot 的令牌」。\n- new-api 是 `OriginTier=second`,**不得**用作隐私敏感的 routing advisor / classifier(遵守 §3.2.1 约束)。\n- fork 兼容:请求同时带 `New-API-User` 与 `User-id` 头;响应信封字段名(`success/data/message`)在部分 fork 可能不同,adapter 需容错解析,失败时回退手动录入。\n- 一个 new-api 订阅默认只自动建/绑定一个渠道;用户如需该站多协议渠道,可在同订阅下手动增建,共用同一 key。\n\n### 8.5.2 订阅 Provider Adapter 框架(多来源余额与接入)\n\n§8.5.1 的 new-api 集成不是特例,而是「订阅 Provider Adapter」框架的一个实现。Phase 4 Item 6 已落地 openai/anthropic/google 的余额刷新 adapter,本框架把它抽象成统一接口,让 new-api 及后续来源(GitHub Copilot、Codex、Nvidia、MiniMax、DeepSeek 等)按相同契约接入,避免每加一个来源就散落一套逻辑(DRY)。\n\n**统一接口**:\n\n```go\n// backend-go/internal/autopilot/subscription/adapter.go\ntype ProviderAdapter interface {\n Kind() string // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | \"copilot\" | \"deepseek\" | ...\n\n // 校验凭据,返回账号身份(用户名/账户标识);失败即凭据无效\n Verify(ctx context.Context, cred Credential) (AccountInfo, error)\n // 查余额/额度;不支持返回 ErrUnsupported,由 UI 降级为手动录入\n FetchBalance(ctx context.Context, cred Credential) (Balance, error)\n // 拉分组倍率;仅 new-api 类中转有,官方直连返回 ErrUnsupported\n FetchGroups(ctx context.Context, cred Credential) (map[string]GroupRatio, error)\n // 自动创建代理 key;官方直连通常不支持(key 即用户 API key),返回 ErrUnsupported\n ProvisionKey(ctx context.Context, cred Credential, tmpl KeyTemplate) (ProvisionedKey, error)\n // 可用模型列表;用于渠道 supportedModels 参考\n FetchModels(ctx context.Context, cred Credential) ([]string, error)\n}\n```\n\n每个能力独立、可选:adapter 只实现自己支持的部分,不支持的统一返回 `ErrUnsupported`(接口隔离原则),UI 据此隐藏/降级对应入口。所有 adapter 共用 `SubscriptionAutoRefresh` 的 opt-in 开关与 `Validate()` 兜底,与 Phase 4 现有防御模式一致。\n\n**能力矩阵**(✅ 支持 / ⚠️ 部分或需确认 / ❌ 不支持,走手动):\n\n| 来源 | Kind | 验证 | 查余额 | 拉分组 | 自动建 key | OriginTier |\n|------|------|:----:|:------:|:------:|:----------:|------------|\n| new-api family | `new_api` | ✅ | ✅ quota | ✅ | ✅ | second |\n| OpenAI 官方 | `openai` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Anthropic 官方 | `anthropic` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Google 官方 | `google` | ✅ | ⚠️ | ❌ | ❌ | first |\n| GitHub Copilot | `copilot` | ⚠️ token | ❌ 无额度概念 | ❌ | ❌ | first |\n| Codex plan | `codex` | ⚠️ | ⚠️ 用量窗口 | ❌ | ❌ | first |\n| Nvidia NIM | `nvidia` | ✅ | ⚠️ credits | ❌ | ❌ | first |\n| MiniMax | `minimax` | ✅ | ⚠️ | ❌ | ❌ | first |\n| DeepSeek | `deepseek` | ✅ | ✅ balance API | ❌ | ❌ | first |\n\n说明:官方直连来源普遍**不支持自动建 key**(用户的 API key 本身就是凭据,直接作为渠道 apiKey 录入即可)、也**没有分组倍率**(价格按官方定价表)。它们的 adapter 价值集中在「验证 + 查余额/用量窗口」,把 §3.2.4 UsageWindow 的官方用量 API 来源坐实。DeepSeek 有独立余额查询端点,可完整支持余额。Copilot 属订阅制、无 token 余额概念,只做验证 + 用量提示。\n\n**接入分层**(YAGNI:不强求一次做全,按价值排期):\n\n```text\nP0(本轮):new_api(完整:验证+余额+分组+建key+自动建渠道)\n —— 覆盖用户最主要的中转站场景\nP1:deepseek(验证+余额)、官方三家余额已在 Phase 4 完成 → 补 UsageWindow 对齐\nP2:nvidia / minimax(验证+credits/余额)\nP3:copilot / codex(验证+用量窗口提示,无余额)\n```\n\n每个来源的具体端点在实现时于 `adapter.go` 各 provider 文件内落地,本设计只固定接口契约与能力矩阵,避免文档与上游 API 变动强耦合。新增来源 = 实现 `ProviderAdapter` + 在能力矩阵登记,不改框架。\n\n**能力缺失与降级**:\n- adapter 任一能力返回 `ErrUnsupported` → 订阅中心对应字段回退为手动录入(余额手填、倍率手填),不报错、不阻塞接入。\n- adapter 验证失败/超时 → 订阅可仍以「手动模式」创建,仅失去自动刷新与自动建 key,其余流程(手填 baseURL+key 建渠道)不受影响。\n- 官方来源无「自动建 key」时,接入流程退化为 §8.4 标准快速添加:用户填 API key,adapter 只补验证与余额。\n- 所有自动刷新遵守 `SubscriptionAutoRefresh` 默认关闭;连续失败自动停用自动刷新并在驾驶舱「人工待办」提示。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**Phase 3A 状态(2026-07-09)**:低风险、不改真实路由语义的子集已完成并接入真实链路——`Manager.collectAll()` 新旧画像对比产出变更事件(`DetectProfileChanges`)、`ProfileChangelogStore`(环形内存 + SQLite,30 天机会性清理)、`EventHub` 内存 pub/sub、`GET /api/health-center/changelog`(历史)+ `GET /api/health-center/events`(WebSocket 实时推送)、`AutoDiscoveryRunner` 发布 `discovery_completed`/`auto_mapping_applied`、前端 `ProfileChangelogTimeline.vue` 接入健康中心页并支持断线自动重连。均为只读展示,未修改 `SmartRouter`/`EndpointAttemptPolicy` 调度逻辑。落地时顺带发现并修复一个真实缺口:浏览器原生 WebSocket 无法设自定义 header,为此给 `middleware.getAPIKey` 增加了 `Sec-WebSocket-Protocol` 鉴权回退(业界标准做法,仅新增一个 key 来源,不降低现有校验)。\n\n**Phase 3B(已全部完成,2026-07-09)**:模型自动映射、自动恢复探测、晋升/降级机制会直接改变真实调度决策,风险明显高于 3A,四个子项已分批全部完成。§12.2 P1.5 的 SQLite schema version / 版本化 migration / `OriginType`&`OriginTier` 旧配置 backfill / `killSwitch`+按 task class/channel disable **已于 2026-07-09 补强完成**(详见 §12.2 P1.5)。**Phase 3B-1(自动恢复探测补全)已于 2026-07-09 完成**:修复了 L2 探测子系统里此前从未打通的两个真实 bug——① `collectAll()` 每轮把新构造的画像整行 Upsert,会把 `ProbeWorker` 刚写入的 `LastProbeAt`/`ProbeSuccess`/`ConsecutiveProbeSuccess` 等字段清零,导致 6 小时探测冷却期形同虚设(修复:`carryForwardProbeFields` 在 Upsert 前搬运 Probe* 字段,不影响 L1 诊断字段的现有覆盖逻辑);② `extractRawAPIKey` 是返回 `KeyMask` 掩码值的占位 stub,探测请求实际带的是假 key(修复:新增 `APIKeyResolver` 回调 + `Manager.ResolveAPIKey`,通过遍历配置里对应渠道的 `APIKeys` 逐个计算 `KeyHashFromAPIKey` 比对来还原明文;resolver 未注入或未命中时 fail-open 跳过探测且不消耗预算)。同时补齐了 `degraded/limited → healthy` 的连续成功恢复路径(新增 `KeyEndpointProfile.ConsecutiveProbeSuccess` 计数器 + `HealthCheckConfig.ProbeRecoveryThreshold`,默认连续 2 次探测成功才恢复,避免单次探测噪声导致状态抖动;探测失败计数器清零)。**Phase 3B-3(StabilityTier 晋降级滞后)已于 2026-07-09 完成**(提交 664d3fff):在原本每轮从瞬时 1h 窗口重算的无记忆 `StabilityTier` 上加一层连续窗口滞后——新增 `KeyEndpointProfile.EffectiveStabilityTier`/`StabilityPendingTier`/`StabilityPendingStreak` 三字段 + 纯函数 `applyStabilityHysteresis`(对称阈值:连续 N 轮同值才切换生效档,`HealthCheckConfig.StabilityHysteresisWindows` 默认 2;首次画像直接采用,不等待阈值,避免新渠道卡在零值),`AggregateChannelProfile` 改读 `EffectiveStabilityTier`(空则回退 `StabilityTier`)。晋升/降级机制的数据模型仅限于 StabilityTier,不改变 QualityTier/SpeedTier/CostTier 语义。**Phase 3B-2(模型自动映射闭环)已于 2026-07-09 完成**(提交 0f02e988,随后修复一处 critical 集成 bug,提交 856372c4):新建 `ModelProfileStore`(SQLite,`model_profiles` 表)+ 扩展 `AutoDiscoveryRunner` 为每个发现模型写入 `ModelProfile`(仅 `AutoManaged` 渠道);新建 `ModelResolver` 实现 §5.4 五步逻辑(手动 `RedirectModel` 短路 → `GetModelProfiles` → `filterByCapabilityFloor` → `rankBySimilarity`);`scheduler` 包新增 `ModelSupportResolverFunc` hook(fail-open,未注入或返回不支持时严格回退到原 `ExplainModelSupport`);`endpoint_policy.go` 的 `resolveMappedModel`(此前是永不触发的死代码——`profile.ModelMapping` 从未被写入)现调用 `ModelResolver`;`handlers/common` 在 `RedirectModel` 之后应用 `MappedModel` 覆盖请求体,并按 `EchoMappedModel` 配置回显 `X-CCX-Mapped-Model`/`X-CCX-Original-Model`/`X-CCX-Mapping-Source` 响应头。三条件安全门控:`ModelMapping.AutoResolve==true`(**默认由死配置的 true 改为 false**)AND `RoutingMode ∈ {assist, auto}` AND 无 `KillSwitch`,三者任一不满足则所有新增代码路径行为与今天字节级一致;且仅 `AutoManaged` 渠道触发,手动配置渠道完全不受影响。对抗性复核发现并修复一个真实 critical bug:`scoreEndpointForKey` 用空 `channelUID` 计算 `EndpointUID` 并写入 `modelByUID`,而 `handlers` 层用真实 `channelUID` 查 `modelByUID`,两者哈希永不相等导致 `MappedModel` 在 key 级调度路径上完全查不到(提交 856372c4 修复:命中画像后改用 `profile.EndpointUID`,新增回归测试锁定该不变量)。至此 Phase 3B 四个子项(自动恢复探测、StabilityTier 滞后、模型自动映射、active filter 前置判定)全部完成;SLO regression 自动回滚仍是独立子系统,留待后续单独立项。\n\n**范围**:\n- [x] 运行时指标驱动画像实时更新(Phase 3A:`collectAll()` 每轮 diff + changelog + WebSocket 推送)\n- [x] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)—— Phase 3B-2(2026-07-09 完成,提交 0f02e988 + 856372c4)\n- [x] active model filter / context filter 支持自动映射前置判定 —— Phase 3B-2:`scheduler.ModelSupportResolverFunc` fail-open 接入 `select.go`\n- [x] 自动恢复探测(limited/dead → healthy)—— Phase 3B-1(2026-07-09 完成):`APIKeyResolver` + `carryForwardProbeFields` + `ConsecutiveProbeSuccess`/`ProbeRecoveryThreshold`\n- [x] 晋升/降级机制(连续成功→升级,连续失败→降级)—— Phase 3B-3(2026-07-09 完成,提交 664d3fff):`StabilityTier` 连续窗口滞后(`EffectiveStabilityTier`),暂不含 QualityTier/SpeedTier/CostTier\n- [x] WebSocket 推送画像变更事件(Phase 3A:`GET /api/health-center/events`)\n- [x] 前端画像变更历史/时间线(Phase 3A:`ProfileChangelogTimeline.vue`)\n\n**预估工期**:2-3 周(3A、3B-1、3B-2、3B-3 均已完成;SLO regression 自动回滚待单独立项评估工期)\n\n### Phase 4:高级特性\n\n**Phase 4 状态(2026-07-09,全部完成)**:8 项全部实现并接入真实链路,均遵循\"默认关闭 + 显式 opt-in\"安全守则。\n\n**范围**:\n- [x] 多维度标签系统扩展(用户自定义标签)——Item 1\n- [x] 成本报表:按用户/模型/渠道/key 统计真实有效成本——Item 2(新增 `proxyKeyMask` 身份贯穿链路:`middleware.GetProxyKeyMask` → `ChannelLog`/`RequestRecord` → SQLite `proxy_key_mask` 列,供 Item 4 复用)\n- [x] SLO regression 自动回滚——Item 3(默认关闭;`SLORollbackConfig`,连续 degrading 窗口数默认 3,不做自动恢复以避免震荡)\n- [x] 渠道推荐:根据使用模式推荐新渠道——Item 4\n- [x] 批量渠道管理(导入/导出/模板)——Item 5\n- [x] 订阅中心 provider adapter:可选自动刷新余额/套餐状态——Item 6(默认关闭;仅 openai/anthropic/google 且显式填写 BillingAPIKey 的订阅生效,中转/公益渠道继续手动维护)\n- [x] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板——Item 7\n- [x] A/B 测试:低比例统计抽样双发——Item 8(默认关闭,默认 1% 抽样 + 每小时 60 次硬预算;主请求路径不受影响,影子请求在主响应返回后异步发起)\n\n**关键实现说明**:\n- Item 3/6/8 均在 `AutopilotRoutingConfig` 新增独立配置块(`SLORollback`/`SubscriptionAutoRefresh`/`ABTest`),`Validate()` 统一做负值/越界兜底,保持与既有字段一致的防御模式。\n- Item 4 直接复用 Item 2 的 `proxyKeyMask` 身份标记,未重新发明身份识别机制。\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- ~~SQLite schema version 和幂等 migration。~~ **已完成**:`ensureSchemaVersion` 基于 `PRAGMA user_version`,当前 7 张表定版为 v1 基线;库版本高于代码版本时返回 error(fail-closed,不做任何写操作),交给下游 fail-open 分支接管。\n- ~~旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。~~ **已完成**:`ensureOriginBackfill` 只补 `\"unknown\"`,不做任何基于 URL/名称的猜测推断;已接线到 `Profiler.DeriveEndpointProfile`,`BreakTieByOriginTier` 现在能吃到真实数据(此前一直是 fallback 的 `unknown`)。\n- ~~profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。~~ **已确认满足并测试锁定**:`main.go` 现有结构本就是 fail-open(`NewProfileStore`/`NewManager` 出错只打日志、`autopilotManager` 保持 `nil`,下游全部有 `nil` 守卫),本次新增 `schema_migration_test.go` 用测试锁定这条不变量。\n- ~~全局 `killSwitch`、按 task class disable、按 channel disable。~~ **已完成**:新增 `AutopilotRoutingConfig.DisabledTaskClasses`/`DisabledChannelUIDs`;`CandidateFilterFor`/`executeFilter`/`BuildPlan` 三处一致接入,`smart_router_invariant_test.go` 覆盖\"task class 禁用后回退默认调度\"\"channel 禁用后不出现在候选里\"\"dry-run 与真实路径行为一致\"三条不变量。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。**待实现**:`TrustedRoutingAdvisor.promotionMode` 目前是 `manual`,shadow→candidate→active 已有人工审查关卡;自动回滚是独立的新子系统(需要基线捕获窗口 + 实时对比 + 阈值判定),留待下一轮单独设计落地。\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | shadow 样本/准确率/critical misroute 门槛;active 后 SLO regression 自动 rollback;全局 kill switch |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "0cae57f06626b389814aabb79e6e2ac6ed0dc764": {"t": 1785759369.7428408, "n": 154628, "h": "fa9a43c74d9b2b81", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.2.3 同订阅多 Key:能力共享与配额负载均衡\n\n**问题**:官方订阅 plan(Claude Max、Codex plan 等)允许用户持有多个 key,或购买多份相同套餐。若每个 endpoint 独立探测与配置,探测预算被浪费、配置重复;请求量大时还需要在同 plan 的多个 key 之间按官方 RPM 限速自动负载均衡,而不是打满第一个 key 才溢出到下一个。\n\n**能力检测去重(同一套餐只需一套能力检测和配置)**:\n\n```text\nSubscriptionUID 相同的 endpoint 共享一份「订阅级能力画像」:\n 模型列表 / vision / tool / reasoning 支持 / 协议兼容开关 → 只探测一次,\n 存在 SubscriptionProfile.SharedCapability,endpoint 画像以引用方式继承。\n\n仍然 per-endpoint 维护的:健康状态、延迟、熔断、用量窗口\n (key 可能被单独封禁/限速,能力相同不代表运行态相同)\n\n继承失效条件:某 key 实测模型列表哈希与订阅级不一致(GroupChangeDetector 信号)\n → 该 key 脱离继承、独立探测,并在 UI 标记「与套餐能力不一致」警告。\n```\n\n**配额负载均衡(Phase 1 shadow 展示,Phase 2 生效)**:\n\n```text\n每 key 维护配额余量视图:\n remainingRPM = officialRPM − 当前窗口已用\n officialRPM 来源优先级:内置 manifest > 用户配置 > RateLimitDiscoverer 建议\n\n同订阅多 key 时按「剩余余量加权」分配请求(余量大者优先、\n窗口最早重置者兜底),替代顺序打满:\n 仅当渠道 autoManaged 且同一 SubscriptionUID 下 key 数 > 1 时启用;\n 否则保持现有 sequential/random key 顺序策略不变。\n\n某 key 的窗口配额耗尽 → 临时降权并切到同订阅其他 key,\n不熔断整个渠道(这是配额耗尽,不是故障)。\n```\n\n### 3.2.4 订阅用量窗口 (Usage Windows)\n\n部分官方 plan 提供 5h / 日 / 周 / 月等窗口的用量查询与限制(如 Claude 订阅的 5h 滚动窗口 + 周配额)。在 key 的位置展示这些窗口,帮助用户掌握使用情况:\n\n```go\ntype UsageWindow struct {\n Window string `json:\"window\"` // \"5h\" | \"day\" | \"week\" | \"month\"\n Used float64 `json:\"used\"` // 已用量\n Limit float64 `json:\"limit\"` // 上限,0 = 未知\n Unit string `json:\"unit\"` // requests | tokens | credits | percent\n ResetsAt time.Time `json:\"resetsAt\"` // 窗口重置时间\n Source string `json:\"source\"` // official_api | response_header | local_metering\n FetchedAt time.Time `json:\"fetchedAt\"`\n}\n// KeyEndpointProfile 与 SubscriptionProfile 均增加 UsageWindows []UsageWindow\n```\n\n数据来源优先级:\n\n```text\n1. 官方用量 API:有 provider adapter 的 plan 主动拉取(§Phase 4 订阅 adapter 的可提前子集)\n2. 响应头:anthropic-ratelimit-* / x-ratelimit-* 等(复用 RateLimitDiscoverer 的信号 tap)\n3. 本地计量兜底:CCX 按 key 累计请求数/token(误差来自窗口起点对齐,标注 Source=local_metering)\n```\n\nUI 要求:key 行内显示主窗口(5h)用量进度条,悬浮展开全部窗口;用量 >80% 黄色预警、超限红色并显示预计重置时间。调度联动(Phase 2):窗口余量进入配额负载均衡权重,窗口耗尽的 key 临时降权而非熔断。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n#### 4.2.1 内置模型清单 (Builtin Models Manifest)\n\n**问题**:部分官方订阅 plan 的入口没有正确的 models 接口——要么不存在(404)、要么需要额外权限、要么返回与订阅实际可用模型不符的列表(典型:Claude 订阅 OAuth 入口、Codex plan 入口、部分官方企业网关)。对这些 baseURL 做模型发现会失败或产出错误画像。\n\n**方案**:CCX 内置「已知官方 baseURL → 模型清单」映射表,命中时直接返回内置内容,完全跳过上游 models 接口:\n\n```go\n// backend-go/internal/config/builtin_models_manifest.go(生成式,随模型注册表更新)\ntype BuiltinModelsManifest struct {\n BaseURLPattern string `json:\"baseUrlPattern\"` // 精确 host 或前缀匹配,如 \"api.anthropic.com\"\n ServiceType string `json:\"serviceType\"` // messages/responses/…\n PlanHint string `json:\"planHint,omitempty\"` // 订阅类型提示(如 \"claude_subscription\")\n ModelIDs []string `json:\"modelIds\"` // 该入口实际可用的模型清单\n DisableProbe bool `json:\"disableProbe\"` // true 时 Discovery 跳过 GET /v1/models\n}\n```\n\n**两个生效位置**:\n\n```text\n1. Discovery(§4.2 流程第 2 步之前):\n baseURL 命中 manifest 且 DisableProbe=true\n → 跳过 GET /v1/models,直接用 ModelIDs 生成 ModelProfile[]\n → ProfileSource = \"builtin_manifest\",不再因 models 接口失败而误判 misconfigured\n\n2. /v1/models 代理响应(现有 Models API):\n 客户端向 CCX 请求 /v1/models 时,聚合结果中该渠道的贡献\n 直接取内置清单,不向上游发起 models 请求\n (渠道显式配置了 supportedModels 时仍以用户配置优先)\n```\n\n**维护与覆盖顺序**:\n\n```text\n渠道显式 supportedModels(用户手动) > 内置 manifest > 上游 models 接口实探 > 内置候选模型回退\n```\n\nmanifest 与模型注册表同源生成(`generated_model_registry.go` 同级),新模型发布时随注册表一起更新;HealthAnalyzer 对命中 manifest 的 endpoint 不将「models 接口 404/403」计入 misconfigured 信号。\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法确定时 domainStrengthScore 一律取 0.5 中性值,该项对所有候选贡献相同、等效于不存在。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设 + 自定义\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n| `custom` | 用户自定义 | 用户自定义 | 高级用户微调,范围 0.0 ~ 3.0 |\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法判定,中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general(中性),domainStrengthScore 对所有候选取 0.5,该项失效。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选最佳匹配\n // 匹配优先级:\n // a. 同模型族(opus→opus, sonnet→sonnet)—— 最高优先\n // b. 同质量档(premium→premium)\n // c. 上下文窗口最接近(不超也不差太多)\n // d. 探测延迟最低\n best := rankBySimilarity(eligible, requestModel, floor)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n#### 8.0.1 实现现状与演进方向(2026-07-09 核实)\n\n四界面在 **Web 前端(`frontend/src`)已大面积落地**,不是纯文档:\n\n| 界面 | Web 实现 | Desktop 实现 |\n|------|----------|--------------|\n| 健康中心 | ✅ `HealthCenterView.vue` + `HealthCenterStats`/`HealthChannelTable`/`HealthChannelDetail`/`ProfileChangelogTimeline`,路由 `/health` | ❌ 无 |\n| 订阅中心 | ✅ `SubscriptionsView.vue` + `SubscriptionPlanTable`,CRUD + 刷新余额,路由 `/subscriptions` | ⚠️ 独立旧实现 `subscriptions/SubscriptionTab.vue`(与 Web 两套代码) |\n| 渠道中心 | ✅ `ChannelOrchestration.vue`(六态 `ChannelHealthBadge`、来源 chips、`autoManaged` 图标齐备) | ❌ 未同步 |\n| 管理面板 | ⚠️ `AutopilotModePanel.vue` 仅暴露 mode/killSwitch/costPreference | ❌ 无 |\n| 驾驶舱 | ✅ `CockpitView.vue`(观测型总览:健康统计+余额汇总+渠道推荐) | ⚠️ `cockpit` tab 渲染的是会话 Dashboard,职责不同 |\n\n**与本设计意图的关系**(对齐用户「自动调度为主、废旧手动驾驶舱」的诉求):\n- **旧的「手动调度驾驶舱」在 Web 端已不存在**。`CockpitView` 已是 §8.7 定义的观测型总览,只回答健康/省钱/风险/待办四问题,不做渠道配置编辑,符合「程序自动选、人只观测+有限干预」的方向。无需再废除,只需继续收敛为纯观测。\n- **统一提交入口已存在**(§8.4 快速添加 + §8.5 订阅中心),new-api 集成(§8.5.1)在此之上把「填 baseURL+key」进一步自动化。\n\n**前端演进待办**(本设计新增/明确):\n1. **补 `ManualRoutingIntent` 前端**:这是用户要的「有限偏向性干预」,后端已有、两个前端均未实现(§8.3 试用意图入口尚缺)。\n2. **Desktop 前端对齐 Web**:健康中心/渠道自动托管/管理面板/观测型驾驶舱在 Desktop 几乎空白,需同步或复用 Web 组件,避免长期维护两套。\n3. **管理面板补字段**:暴露 `l2ProbeEnabled`、探测预算、new-api/多来源订阅 adapter 开关(§8.5.2)等,当前只有 3 个字段。\n4. **订阅中心接 §8.5.1/§8.5.2**:新增「new-api」订阅类型与 Provider Adapter 能力矩阵驱动的动态表单。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.5.1 new-api 订阅集成(统一账号接入)\n\n**目标**:用户在订阅中心手填一个 new-api(及其 fork)账号的「系统访问令牌」即可,CCX 自动用该令牌创建代理专用 key、拉取分组倍率、查询额度余额,并把「站点 baseURL + 新建 key」自动落成一个上游渠道,交给 Autopilot 探测与调度。\n\n**范围约束**:\n- 只使用 new-api 用户在「个人设置」生成的 **系统访问令牌(access_token)** 一种凭据;令牌用于 API 调用的身份验证,属敏感数据,须妥善加密存储。\n- **不做** cookie 登录 + 2FA + 导出后台渠道明文 key 的管理员迁移路径(all-api-hub 的 newApiSession 能力),本设计不涉及。\n- new-api 归入 `OriginType=relay`、`OriginTier=second`(中转站),信任等级不因自动化提升;实时质量仍由运行画像决定。\n- 认证令牌像其他订阅凭据一样由用户手动录入,CCX 不代替用户在 new-api 侧注册或登录。\n\n**认证与凭据模型**:\n\nnew-api family 的管理接口鉴权(依据 all-api-hub 传输层实现):\n\n```text\nAuthorization: Bearer # 系统访问令牌;raw 模式可去掉 Bearer 前缀\nNew-API-User: # 当前用户 ID(必带)\nUser-id: # fork 兼容回退(Veloera/voapi/Super-API 等)\nContent-Type: application/json\n```\n\n统一响应信封:`{ \"success\": bool, \"data\": T, \"message\": string }`;解析时先判 `success`,再取 `data`。\n\n**SubscriptionProfile 扩展**:\n\n在 §3.2.2 的 `SubscriptionProfile` 基础上,为可自动接入的订阅新增 provider 集成字段(沿用「默认关闭 + 显式 opt-in」守则):\n\n```go\n// SubscriptionProfile 新增字段(复用 Phase 4 Item 6 的 SubscriptionAutoRefresh 开关)\ntype SubscriptionIntegration struct {\n Kind string `json:\"kind\"` // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | ...\n BaseURL string `json:\"baseUrl\"` // new-api 站点地址(同时作为新建渠道的上游 baseURL)\n AccessToken string `json:\"-\"` // 系统访问令牌;加密存储,不出 API 响应\n UserID string `json:\"userId\"` // New-API-User 头所需\n AuthTokenMode string `json:\"authTokenMode,omitempty\"` // \"bearer\"(默认) | \"raw\"\n // 自动建 key 的模板\n ProvisionKeyName string `json:\"provisionKeyName,omitempty\"` // 默认 \"ccx-autopilot\"\n ProvisionGroup string `json:\"provisionGroup,omitempty\"` // 建 key 时指定分组,空=默认分组\n ProvisionModels []string `json:\"provisionModels,omitempty\"` // model_limits,空=不限制\n // 自动同步产物(回填,只读展示)\n ProvisionedTokenID int `json:\"provisionedTokenId,omitempty\"`\n LinkedChannelUID string `json:\"linkedChannelUid,omitempty\"`\n}\n```\n\n`AccessToken` 必须与现有敏感字段一样走加密存储,序列化到前端时脱敏(仅显示尾部若干位)。\n\n**new-api 接口清单**(本集成实际调用的子集,路径以主线 new-api 为准,字段名保持与上游一致):\n\n| 用途 | 方法 | 路径 | 请求体 | 响应关键字段 |\n|------|------|------|--------|--------------|\n| 校验令牌 + 查用户/余额 | GET | `/api/user/self` | — | `data.id`、`data.username`、`data.quota`(剩余额度)、`data.used_quota` |\n| 拉分组倍率 | GET | `/api/user/self/groups` | — | `data: { : { desc, ratio } }` |\n| 站点全部分组 | GET | `/api/group` | — | `data: string[]` |\n| 账号可用模型 | GET | `/api/user/models` | — | `data: string[]` |\n| 创建代理 key | POST | `/api/token/` | `CreateTokenRequest`(见下) | `data.key` / `success` |\n| key 列表 | GET | `/api/token/?p={p}&size={n}` | — | `data.items[]`(含 `id/key/name/status/remain_quota`) |\n| 更新 key | PUT | `/api/token/` | `{ ...token, id }` | `success` |\n| 删除 key | DELETE | `/api/token/{id}` | — | `success` |\n\n`CreateTokenRequest`:\n\n```json\n{\n \"name\": \"ccx-autopilot\",\n \"remain_quota\": 0,\n \"expired_time\": -1,\n \"unlimited_quota\": true,\n \"model_limits_enabled\": false,\n \"model_limits\": \"\",\n \"allow_ips\": \"\",\n \"group\": \"\"\n}\n```\n\n`quota` 是 new-api 内部额度单位(非美元),换算比例读 `GET /api/status` 的 `price` / `PaymentUSDRate`(无鉴权),或允许用户在订阅中手填换算率;MVP 直接以 quota 原值展示 + 站点换算率估算余额。\n\n**接入流程**(订阅中心「新增订阅 → 类型选 new-api」):\n\n```text\n1. 用户输入:站点 baseURL + 系统访问令牌(access_token) + userId\n (userId 可选自动获取:先用令牌调 /api/user/self 拿 data.id 回填)\n2. 校验:GET /api/user/self\n ├─ success=false / 401 → 令牌无效或 userId 不匹配,报错停止\n └─ 成功 → 记录 username、quota,创建 SubscriptionProfile(provider=new_api, originTier=second)\n3. 拉分组:GET /api/user/self/groups → 写入 GroupMultipliers({group: ratio})\n GET /api/user/models → 记录账号可用模型,供渠道 supportedModels 参考\n4. 建 key:POST /api/token/(按 ProvisionKeyName/Group/Models 模板)\n ├─ 已存在同名 ccx-autopilot key → 复用其 id,不重复创建(列表比对 name)\n └─ 新建成功 → 回填 ProvisionedTokenID,取 data.key 作为渠道 apiKey\n5. 自动建渠道:用 baseURL + 新建 key 创建一个上游渠道\n ├─ channelKind 由用户选择或按站点探测(messages/chat/responses/gemini)\n ├─ status=unknown,autoManaged=true,OriginType=relay\n ├─ 建立 SubscriptionUID → ChannelUID 链接(回填 LinkedChannelUID)\n └─ 触发 §8.4 的 Discovery + 能力测试标准流程\n6. 完成:订阅卡片显示余额、分组倍率、已绑定渠道、探测进度\n```\n\n关键点:new-api 集成**不额外造探测逻辑**,第 5 步之后完全复用现有 §8.4「添加并探测」链路;集成层只负责「令牌 → key + 分组 + 余额 + baseURL」的自动填充,把手填 baseURL/key 的动作自动化掉。\n\n**刷新与同步**(复用 `POST /api/subscriptions/{uid}/refresh`):\n\n| 触发 | 动作 |\n|------|------|\n| 手动刷新余额 | `GET /api/user/self` 更新 `Balance` + `BalanceUpdatedAt` |\n| 定时刷新(可选,opt-in) | 复用 Phase 4 Item 6 `SubscriptionAutoRefresh`;new-api 作为 `relay` provider adapter 纳入,默认关闭 |\n| 分组倍率变更 | 刷新时对比 `/api/user/self/groups`,`GroupMultipliers` 变化 → 更新并提示「套餐倍率已变」 |\n| key 失效检测 | 渠道侧探测到该 key 全 401 → 订阅卡片提示「key 可能已删除/禁用」,提供「重新建 key」一键动作(重跑第 4-5 步,复用旧渠道,只换 apiKey) |\n| 可用模型变更 | `/api/user/models` 结果哈希变化 → 触发对应渠道重新 Discovery |\n\n**安全与边界**:\n- `access_token` 权限等同 new-api 账户,须加密存储、脱敏展示、禁止写入日志;刷新失败连续 N 次自动停用该订阅的自动刷新,只保留手动。\n- 自动建 key 默认 `unlimited_quota=true` 但可由用户改为限额模板;建 key 是有副作用的写操作,须在接入前明确告知用户「将在 new-api 侧创建一个名为 ccx-autopilot 的令牌」。\n- new-api 是 `OriginTier=second`,**不得**用作隐私敏感的 routing advisor / classifier(遵守 §3.2.1 约束)。\n- fork 兼容:请求同时带 `New-API-User` 与 `User-id` 头;响应信封字段名(`success/data/message`)在部分 fork 可能不同,adapter 需容错解析,失败时回退手动录入。\n- 一个 new-api 订阅默认只自动建/绑定一个渠道;用户如需该站多协议渠道,可在同订阅下手动增建,共用同一 key。\n\n### 8.5.2 订阅 Provider Adapter 框架(多来源余额与接入)\n\n§8.5.1 的 new-api 集成不是特例,而是「订阅 Provider Adapter」框架的一个实现。Phase 4 Item 6 已落地 openai/anthropic/google 的余额刷新 adapter,本框架把它抽象成统一接口,让 new-api 及后续来源(GitHub Copilot、Codex、Nvidia、MiniMax、DeepSeek 等)按相同契约接入,避免每加一个来源就散落一套逻辑(DRY)。\n\n**统一接口**:\n\n```go\n// backend-go/internal/autopilot/subscription/adapter.go\ntype ProviderAdapter interface {\n Kind() string // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | \"copilot\" | \"deepseek\" | ...\n\n // 校验凭据,返回账号身份(用户名/账户标识);失败即凭据无效\n Verify(ctx context.Context, cred Credential) (AccountInfo, error)\n // 查余额/额度;不支持返回 ErrUnsupported,由 UI 降级为手动录入\n FetchBalance(ctx context.Context, cred Credential) (Balance, error)\n // 拉分组倍率;仅 new-api 类中转有,官方直连返回 ErrUnsupported\n FetchGroups(ctx context.Context, cred Credential) (map[string]GroupRatio, error)\n // 自动创建代理 key;官方直连通常不支持(key 即用户 API key),返回 ErrUnsupported\n ProvisionKey(ctx context.Context, cred Credential, tmpl KeyTemplate) (ProvisionedKey, error)\n // 可用模型列表;用于渠道 supportedModels 参考\n FetchModels(ctx context.Context, cred Credential) ([]string, error)\n}\n```\n\n每个能力独立、可选:adapter 只实现自己支持的部分,不支持的统一返回 `ErrUnsupported`(接口隔离原则),UI 据此隐藏/降级对应入口。所有 adapter 共用 `SubscriptionAutoRefresh` 的 opt-in 开关与 `Validate()` 兜底,与 Phase 4 现有防御模式一致。\n\n**能力矩阵**(✅ 支持 / ⚠️ 部分或需确认 / ❌ 不支持,走手动):\n\n| 来源 | Kind | 验证 | 查余额 | 拉分组 | 自动建 key | OriginTier |\n|------|------|:----:|:------:|:------:|:----------:|------------|\n| new-api family | `new_api` | ✅ | ✅ quota | ✅ | ✅ | second |\n| OpenAI 官方 | `openai` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Anthropic 官方 | `anthropic` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Google 官方 | `google` | ✅ | ⚠️ | ❌ | ❌ | first |\n| GitHub Copilot | `copilot` | ⚠️ token | ❌ 无额度概念 | ❌ | ❌ | first |\n| Codex plan | `codex` | ⚠️ | ⚠️ 用量窗口 | ❌ | ❌ | first |\n| Nvidia NIM | `nvidia` | ✅ | ⚠️ credits | ❌ | ❌ | first |\n| MiniMax | `minimax` | ✅ | ⚠️ | ❌ | ❌ | first |\n| DeepSeek | `deepseek` | ✅ | ✅ balance API | ❌ | ❌ | first |\n\n说明:官方直连来源普遍**不支持自动建 key**(用户的 API key 本身就是凭据,直接作为渠道 apiKey 录入即可)、也**没有分组倍率**(价格按官方定价表)。它们的 adapter 价值集中在「验证 + 查余额/用量窗口」,把 §3.2.4 UsageWindow 的官方用量 API 来源坐实。DeepSeek 有独立余额查询端点,可完整支持余额。Copilot 属订阅制、无 token 余额概念,只做验证 + 用量提示。\n\n**接入分层**(YAGNI:不强求一次做全,按价值排期):\n\n```text\nP0(本轮):new_api(完整:验证+余额+分组+建key+自动建渠道)\n —— 覆盖用户最主要的中转站场景\nP1:deepseek(验证+余额)、官方三家余额已在 Phase 4 完成 → 补 UsageWindow 对齐\nP2:nvidia / minimax(验证+credits/余额)\nP3:copilot / codex(验证+用量窗口提示,无余额)\n```\n\n每个来源的具体端点在实现时于 `adapter.go` 各 provider 文件内落地,本设计只固定接口契约与能力矩阵,避免文档与上游 API 变动强耦合。新增来源 = 实现 `ProviderAdapter` + 在能力矩阵登记,不改框架。\n\n**能力缺失与降级**:\n- adapter 任一能力返回 `ErrUnsupported` → 订阅中心对应字段回退为手动录入(余额手填、倍率手填),不报错、不阻塞接入。\n- adapter 验证失败/超时 → 订阅可仍以「手动模式」创建,仅失去自动刷新与自动建 key,其余流程(手填 baseURL+key 建渠道)不受影响。\n- 官方来源无「自动建 key」时,接入流程退化为 §8.4 标准快速添加:用户填 API key,adapter 只补验证与余额。\n- 所有自动刷新遵守 `SubscriptionAutoRefresh` 默认关闭;连续失败自动停用自动刷新并在驾驶舱「人工待办」提示。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**Phase 3A 状态(2026-07-09)**:低风险、不改真实路由语义的子集已完成并接入真实链路——`Manager.collectAll()` 新旧画像对比产出变更事件(`DetectProfileChanges`)、`ProfileChangelogStore`(环形内存 + SQLite,30 天机会性清理)、`EventHub` 内存 pub/sub、`GET /api/health-center/changelog`(历史)+ `GET /api/health-center/events`(WebSocket 实时推送)、`AutoDiscoveryRunner` 发布 `discovery_completed`/`auto_mapping_applied`、前端 `ProfileChangelogTimeline.vue` 接入健康中心页并支持断线自动重连。均为只读展示,未修改 `SmartRouter`/`EndpointAttemptPolicy` 调度逻辑。落地时顺带发现并修复一个真实缺口:浏览器原生 WebSocket 无法设自定义 header,为此给 `middleware.getAPIKey` 增加了 `Sec-WebSocket-Protocol` 鉴权回退(业界标准做法,仅新增一个 key 来源,不降低现有校验)。\n\n**Phase 3B(已全部完成,2026-07-09)**:模型自动映射、自动恢复探测、晋升/降级机制会直接改变真实调度决策,风险明显高于 3A,四个子项已分批全部完成。§12.2 P1.5 的 SQLite schema version / 版本化 migration / `OriginType`&`OriginTier` 旧配置 backfill / `killSwitch`+按 task class/channel disable **已于 2026-07-09 补强完成**(详见 §12.2 P1.5)。**Phase 3B-1(自动恢复探测补全)已于 2026-07-09 完成**:修复了 L2 探测子系统里此前从未打通的两个真实 bug——① `collectAll()` 每轮把新构造的画像整行 Upsert,会把 `ProbeWorker` 刚写入的 `LastProbeAt`/`ProbeSuccess`/`ConsecutiveProbeSuccess` 等字段清零,导致 6 小时探测冷却期形同虚设(修复:`carryForwardProbeFields` 在 Upsert 前搬运 Probe* 字段,不影响 L1 诊断字段的现有覆盖逻辑);② `extractRawAPIKey` 是返回 `KeyMask` 掩码值的占位 stub,探测请求实际带的是假 key(修复:新增 `APIKeyResolver` 回调 + `Manager.ResolveAPIKey`,通过遍历配置里对应渠道的 `APIKeys` 逐个计算 `KeyHashFromAPIKey` 比对来还原明文;resolver 未注入或未命中时 fail-open 跳过探测且不消耗预算)。同时补齐了 `degraded/limited → healthy` 的连续成功恢复路径(新增 `KeyEndpointProfile.ConsecutiveProbeSuccess` 计数器 + `HealthCheckConfig.ProbeRecoveryThreshold`,默认连续 2 次探测成功才恢复,避免单次探测噪声导致状态抖动;探测失败计数器清零)。**Phase 3B-3(StabilityTier 晋降级滞后)已于 2026-07-09 完成**(提交 664d3fff):在原本每轮从瞬时 1h 窗口重算的无记忆 `StabilityTier` 上加一层连续窗口滞后——新增 `KeyEndpointProfile.EffectiveStabilityTier`/`StabilityPendingTier`/`StabilityPendingStreak` 三字段 + 纯函数 `applyStabilityHysteresis`(对称阈值:连续 N 轮同值才切换生效档,`HealthCheckConfig.StabilityHysteresisWindows` 默认 2;首次画像直接采用,不等待阈值,避免新渠道卡在零值),`AggregateChannelProfile` 改读 `EffectiveStabilityTier`(空则回退 `StabilityTier`)。晋升/降级机制的数据模型仅限于 StabilityTier,不改变 QualityTier/SpeedTier/CostTier 语义。**Phase 3B-2(模型自动映射闭环)已于 2026-07-09 完成**(提交 0f02e988,随后修复一处 critical 集成 bug,提交 856372c4):新建 `ModelProfileStore`(SQLite,`model_profiles` 表)+ 扩展 `AutoDiscoveryRunner` 为每个发现模型写入 `ModelProfile`(仅 `AutoManaged` 渠道);新建 `ModelResolver` 实现 §5.4 五步逻辑(手动 `RedirectModel` 短路 → `GetModelProfiles` → `filterByCapabilityFloor` → `rankBySimilarity`);`scheduler` 包新增 `ModelSupportResolverFunc` hook(fail-open,未注入或返回不支持时严格回退到原 `ExplainModelSupport`);`endpoint_policy.go` 的 `resolveMappedModel`(此前是永不触发的死代码——`profile.ModelMapping` 从未被写入)现调用 `ModelResolver`;`handlers/common` 在 `RedirectModel` 之后应用 `MappedModel` 覆盖请求体,并按 `EchoMappedModel` 配置回显 `X-CCX-Mapped-Model`/`X-CCX-Original-Model`/`X-CCX-Mapping-Source` 响应头。三条件安全门控:`ModelMapping.AutoResolve==true`(**默认由死配置的 true 改为 false**)AND `RoutingMode ∈ {assist, auto}` AND 无 `KillSwitch`,三者任一不满足则所有新增代码路径行为与今天字节级一致;且仅 `AutoManaged` 渠道触发,手动配置渠道完全不受影响。对抗性复核发现并修复一个真实 critical bug:`scoreEndpointForKey` 用空 `channelUID` 计算 `EndpointUID` 并写入 `modelByUID`,而 `handlers` 层用真实 `channelUID` 查 `modelByUID`,两者哈希永不相等导致 `MappedModel` 在 key 级调度路径上完全查不到(提交 856372c4 修复:命中画像后改用 `profile.EndpointUID`,新增回归测试锁定该不变量)。至此 Phase 3B 四个子项(自动恢复探测、StabilityTier 滞后、模型自动映射、active filter 前置判定)全部完成;SLO regression 自动回滚仍是独立子系统,留待后续单独立项。\n\n**范围**:\n- [x] 运行时指标驱动画像实时更新(Phase 3A:`collectAll()` 每轮 diff + changelog + WebSocket 推送)\n- [x] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)—— Phase 3B-2(2026-07-09 完成,提交 0f02e988 + 856372c4)\n- [x] active model filter / context filter 支持自动映射前置判定 —— Phase 3B-2:`scheduler.ModelSupportResolverFunc` fail-open 接入 `select.go`\n- [x] 自动恢复探测(limited/dead → healthy)—— Phase 3B-1(2026-07-09 完成):`APIKeyResolver` + `carryForwardProbeFields` + `ConsecutiveProbeSuccess`/`ProbeRecoveryThreshold`\n- [x] 晋升/降级机制(连续成功→升级,连续失败→降级)—— Phase 3B-3(2026-07-09 完成,提交 664d3fff):`StabilityTier` 连续窗口滞后(`EffectiveStabilityTier`),暂不含 QualityTier/SpeedTier/CostTier\n- [x] WebSocket 推送画像变更事件(Phase 3A:`GET /api/health-center/events`)\n- [x] 前端画像变更历史/时间线(Phase 3A:`ProfileChangelogTimeline.vue`)\n\n**预估工期**:2-3 周(3A、3B-1、3B-2、3B-3 均已完成;SLO regression 自动回滚待单独立项评估工期)\n\n### Phase 4:高级特性\n\n**Phase 4 状态(2026-07-09,全部完成)**:8 项全部实现并接入真实链路,均遵循\"默认关闭 + 显式 opt-in\"安全守则。\n\n**范围**:\n- [x] 多维度标签系统扩展(用户自定义标签)——Item 1\n- [x] 成本报表:按用户/模型/渠道/key 统计真实有效成本——Item 2(新增 `proxyKeyMask` 身份贯穿链路:`middleware.GetProxyKeyMask` → `ChannelLog`/`RequestRecord` → SQLite `proxy_key_mask` 列,供 Item 4 复用)\n- [x] SLO regression 自动回滚——Item 3(默认关闭;`SLORollbackConfig`,连续 degrading 窗口数默认 3,不做自动恢复以避免震荡)\n- [x] 渠道推荐:根据使用模式推荐新渠道——Item 4\n- [x] 批量渠道管理(导入/导出/模板)——Item 5\n- [x] 订阅中心 provider adapter:可选自动刷新余额/套餐状态——Item 6(默认关闭;仅 openai/anthropic/google 且显式填写 BillingAPIKey 的订阅生效,中转/公益渠道继续手动维护)\n- [x] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板——Item 7\n- [x] A/B 测试:低比例统计抽样双发——Item 8(默认关闭,默认 1% 抽样 + 每小时 60 次硬预算;主请求路径不受影响,影子请求在主响应返回后异步发起)\n\n**关键实现说明**:\n- Item 3/6/8 均在 `AutopilotRoutingConfig` 新增独立配置块(`SLORollback`/`SubscriptionAutoRefresh`/`ABTest`),`Validate()` 统一做负值/越界兜底,保持与既有字段一致的防御模式。\n- Item 4 直接复用 Item 2 的 `proxyKeyMask` 身份标记,未重新发明身份识别机制。\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- ~~SQLite schema version 和幂等 migration。~~ **已完成**:`ensureSchemaVersion` 基于 `PRAGMA user_version`,当前 7 张表定版为 v1 基线;库版本高于代码版本时返回 error(fail-closed,不做任何写操作),交给下游 fail-open 分支接管。\n- ~~旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。~~ **已完成**:`ensureOriginBackfill` 只补 `\"unknown\"`,不做任何基于 URL/名称的猜测推断;已接线到 `Profiler.DeriveEndpointProfile`,`BreakTieByOriginTier` 现在能吃到真实数据(此前一直是 fallback 的 `unknown`)。\n- ~~profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。~~ **已确认满足并测试锁定**:`main.go` 现有结构本就是 fail-open(`NewProfileStore`/`NewManager` 出错只打日志、`autopilotManager` 保持 `nil`,下游全部有 `nil` 守卫),本次新增 `schema_migration_test.go` 用测试锁定这条不变量。\n- ~~全局 `killSwitch`、按 task class disable、按 channel disable。~~ **已完成**:新增 `AutopilotRoutingConfig.DisabledTaskClasses`/`DisabledChannelUIDs`;`CandidateFilterFor`/`executeFilter`/`BuildPlan` 三处一致接入,`smart_router_invariant_test.go` 覆盖\"task class 禁用后回退默认调度\"\"channel 禁用后不出现在候选里\"\"dry-run 与真实路径行为一致\"三条不变量。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。**待实现**:`TrustedRoutingAdvisor.promotionMode` 目前是 `manual`,shadow→candidate→active 已有人工审查关卡;自动回滚是独立的新子系统(需要基线捕获窗口 + 实时对比 + 阈值判定),留待下一轮单独设计落地。\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | shadow 样本/准确率/critical misroute 门槛;active 后 SLO regression 自动 rollback;全局 kill switch |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "eeb1ee45e2d6468ffc1fe947ac939123c7d93042": {"t": 1785759372.3735483, "n": 145255, "h": "f0c1be5a73a52cb5", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.2.3 同订阅多 Key:能力共享与配额负载均衡\n\n**问题**:官方订阅 plan(Claude Max、Codex plan 等)允许用户持有多个 key,或购买多份相同套餐。若每个 endpoint 独立探测与配置,探测预算被浪费、配置重复;请求量大时还需要在同 plan 的多个 key 之间按官方 RPM 限速自动负载均衡,而不是打满第一个 key 才溢出到下一个。\n\n**能力检测去重(同一套餐只需一套能力检测和配置)**:\n\n```text\nSubscriptionUID 相同的 endpoint 共享一份「订阅级能力画像」:\n 模型列表 / vision / tool / reasoning 支持 / 协议兼容开关 → 只探测一次,\n 存在 SubscriptionProfile.SharedCapability,endpoint 画像以引用方式继承。\n\n仍然 per-endpoint 维护的:健康状态、延迟、熔断、用量窗口\n (key 可能被单独封禁/限速,能力相同不代表运行态相同)\n\n继承失效条件:某 key 实测模型列表哈希与订阅级不一致(GroupChangeDetector 信号)\n → 该 key 脱离继承、独立探测,并在 UI 标记「与套餐能力不一致」警告。\n```\n\n**配额负载均衡(Phase 1 shadow 展示,Phase 2 生效)**:\n\n```text\n每 key 维护配额余量视图:\n remainingRPM = officialRPM − 当前窗口已用\n officialRPM 来源优先级:内置 manifest > 用户配置 > RateLimitDiscoverer 建议\n\n同订阅多 key 时按「剩余余量加权」分配请求(余量大者优先、\n窗口最早重置者兜底),替代顺序打满:\n 仅当渠道 autoManaged 且同一 SubscriptionUID 下 key 数 > 1 时启用;\n 否则保持现有 sequential/random key 顺序策略不变。\n\n某 key 的窗口配额耗尽 → 临时降权并切到同订阅其他 key,\n不熔断整个渠道(这是配额耗尽,不是故障)。\n```\n\n### 3.2.4 订阅用量窗口 (Usage Windows)\n\n部分官方 plan 提供 5h / 日 / 周 / 月等窗口的用量查询与限制(如 Claude 订阅的 5h 滚动窗口 + 周配额)。在 key 的位置展示这些窗口,帮助用户掌握使用情况:\n\n```go\ntype UsageWindow struct {\n Window string `json:\"window\"` // \"5h\" | \"day\" | \"week\" | \"month\"\n Used float64 `json:\"used\"` // 已用量\n Limit float64 `json:\"limit\"` // 上限,0 = 未知\n Unit string `json:\"unit\"` // requests | tokens | credits | percent\n ResetsAt time.Time `json:\"resetsAt\"` // 窗口重置时间\n Source string `json:\"source\"` // official_api | response_header | local_metering\n FetchedAt time.Time `json:\"fetchedAt\"`\n}\n// KeyEndpointProfile 与 SubscriptionProfile 均增加 UsageWindows []UsageWindow\n```\n\n数据来源优先级:\n\n```text\n1. 官方用量 API:有 provider adapter 的 plan 主动拉取(§Phase 4 订阅 adapter 的可提前子集)\n2. 响应头:anthropic-ratelimit-* / x-ratelimit-* 等(复用 RateLimitDiscoverer 的信号 tap)\n3. 本地计量兜底:CCX 按 key 累计请求数/token(误差来自窗口起点对齐,标注 Source=local_metering)\n```\n\nUI 要求:key 行内显示主窗口(5h)用量进度条,悬浮展开全部窗口;用量 >80% 黄色预警、超限红色并显示预计重置时间。调度联动(Phase 2):窗口余量进入配额负载均衡权重,窗口耗尽的 key 临时降权而非熔断。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n#### 4.2.1 内置模型清单 (Builtin Models Manifest)\n\n**问题**:部分官方订阅 plan 的入口没有正确的 models 接口——要么不存在(404)、要么需要额外权限、要么返回与订阅实际可用模型不符的列表(典型:Claude 订阅 OAuth 入口、Codex plan 入口、部分官方企业网关)。对这些 baseURL 做模型发现会失败或产出错误画像。\n\n**方案**:CCX 内置「已知官方 baseURL → 模型清单」映射表,命中时直接返回内置内容,完全跳过上游 models 接口:\n\n```go\n// backend-go/internal/config/builtin_models_manifest.go(生成式,随模型注册表更新)\ntype BuiltinModelsManifest struct {\n BaseURLPattern string `json:\"baseUrlPattern\"` // 精确 host 或前缀匹配,如 \"api.anthropic.com\"\n ServiceType string `json:\"serviceType\"` // messages/responses/…\n PlanHint string `json:\"planHint,omitempty\"` // 订阅类型提示(如 \"claude_subscription\")\n ModelIDs []string `json:\"modelIds\"` // 该入口实际可用的模型清单\n DisableProbe bool `json:\"disableProbe\"` // true 时 Discovery 跳过 GET /v1/models\n}\n```\n\n**两个生效位置**:\n\n```text\n1. Discovery(§4.2 流程第 2 步之前):\n baseURL 命中 manifest 且 DisableProbe=true\n → 跳过 GET /v1/models,直接用 ModelIDs 生成 ModelProfile[]\n → ProfileSource = \"builtin_manifest\",不再因 models 接口失败而误判 misconfigured\n\n2. /v1/models 代理响应(现有 Models API):\n 客户端向 CCX 请求 /v1/models 时,聚合结果中该渠道的贡献\n 直接取内置清单,不向上游发起 models 请求\n (渠道显式配置了 supportedModels 时仍以用户配置优先)\n```\n\n**维护与覆盖顺序**:\n\n```text\n渠道显式 supportedModels(用户手动) > 内置 manifest > 上游 models 接口实探 > 内置候选模型回退\n```\n\nmanifest 与模型注册表同源生成(`generated_model_registry.go` 同级),新模型发布时随注册表一起更新;HealthAnalyzer 对命中 manifest 的 endpoint 不将「models 接口 404/403」计入 misconfigured 信号。\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法确定时 domainStrengthScore 一律取 0.5 中性值,该项对所有候选贡献相同、等效于不存在。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设 + 自定义\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n| `custom` | 用户自定义 | 用户自定义 | 高级用户微调,范围 0.0 ~ 3.0 |\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法判定,中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general(中性),domainStrengthScore 对所有候选取 0.5,该项失效。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选最佳匹配\n // 匹配优先级:\n // a. 同模型族(opus→opus, sonnet→sonnet)—— 最高优先\n // b. 同质量档(premium→premium)\n // c. 上下文窗口最接近(不超也不差太多)\n // d. 探测延迟最低\n best := rankBySimilarity(eligible, requestModel, floor)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**Phase 3A 状态(2026-07-09)**:低风险、不改真实路由语义的子集已完成并接入真实链路——`Manager.collectAll()` 新旧画像对比产出变更事件(`DetectProfileChanges`)、`ProfileChangelogStore`(环形内存 + SQLite,30 天机会性清理)、`EventHub` 内存 pub/sub、`GET /api/health-center/changelog`(历史)+ `GET /api/health-center/events`(WebSocket 实时推送)、`AutoDiscoveryRunner` 发布 `discovery_completed`/`auto_mapping_applied`、前端 `ProfileChangelogTimeline.vue` 接入健康中心页并支持断线自动重连。均为只读展示,未修改 `SmartRouter`/`EndpointAttemptPolicy` 调度逻辑。落地时顺带发现并修复一个真实缺口:浏览器原生 WebSocket 无法设自定义 header,为此给 `middleware.getAPIKey` 增加了 `Sec-WebSocket-Protocol` 鉴权回退(业界标准做法,仅新增一个 key 来源,不降低现有校验)。\n\n**Phase 3B(已全部完成,2026-07-09)**:模型自动映射、自动恢复探测、晋升/降级机制会直接改变真实调度决策,风险明显高于 3A,四个子项已分批全部完成。§12.2 P1.5 的 SQLite schema version / 版本化 migration / `OriginType`&`OriginTier` 旧配置 backfill / `killSwitch`+按 task class/channel disable **已于 2026-07-09 补强完成**(详见 §12.2 P1.5)。**Phase 3B-1(自动恢复探测补全)已于 2026-07-09 完成**:修复了 L2 探测子系统里此前从未打通的两个真实 bug——① `collectAll()` 每轮把新构造的画像整行 Upsert,会把 `ProbeWorker` 刚写入的 `LastProbeAt`/`ProbeSuccess`/`ConsecutiveProbeSuccess` 等字段清零,导致 6 小时探测冷却期形同虚设(修复:`carryForwardProbeFields` 在 Upsert 前搬运 Probe* 字段,不影响 L1 诊断字段的现有覆盖逻辑);② `extractRawAPIKey` 是返回 `KeyMask` 掩码值的占位 stub,探测请求实际带的是假 key(修复:新增 `APIKeyResolver` 回调 + `Manager.ResolveAPIKey`,通过遍历配置里对应渠道的 `APIKeys` 逐个计算 `KeyHashFromAPIKey` 比对来还原明文;resolver 未注入或未命中时 fail-open 跳过探测且不消耗预算)。同时补齐了 `degraded/limited → healthy` 的连续成功恢复路径(新增 `KeyEndpointProfile.ConsecutiveProbeSuccess` 计数器 + `HealthCheckConfig.ProbeRecoveryThreshold`,默认连续 2 次探测成功才恢复,避免单次探测噪声导致状态抖动;探测失败计数器清零)。**Phase 3B-3(StabilityTier 晋降级滞后)已于 2026-07-09 完成**(提交 664d3fff):在原本每轮从瞬时 1h 窗口重算的无记忆 `StabilityTier` 上加一层连续窗口滞后——新增 `KeyEndpointProfile.EffectiveStabilityTier`/`StabilityPendingTier`/`StabilityPendingStreak` 三字段 + 纯函数 `applyStabilityHysteresis`(对称阈值:连续 N 轮同值才切换生效档,`HealthCheckConfig.StabilityHysteresisWindows` 默认 2;首次画像直接采用,不等待阈值,避免新渠道卡在零值),`AggregateChannelProfile` 改读 `EffectiveStabilityTier`(空则回退 `StabilityTier`)。晋升/降级机制的数据模型仅限于 StabilityTier,不改变 QualityTier/SpeedTier/CostTier 语义。**Phase 3B-2(模型自动映射闭环)已于 2026-07-09 完成**(提交 0f02e988,随后修复一处 critical 集成 bug,提交 856372c4):新建 `ModelProfileStore`(SQLite,`model_profiles` 表)+ 扩展 `AutoDiscoveryRunner` 为每个发现模型写入 `ModelProfile`(仅 `AutoManaged` 渠道);新建 `ModelResolver` 实现 §5.4 五步逻辑(手动 `RedirectModel` 短路 → `GetModelProfiles` → `filterByCapabilityFloor` → `rankBySimilarity`);`scheduler` 包新增 `ModelSupportResolverFunc` hook(fail-open,未注入或返回不支持时严格回退到原 `ExplainModelSupport`);`endpoint_policy.go` 的 `resolveMappedModel`(此前是永不触发的死代码——`profile.ModelMapping` 从未被写入)现调用 `ModelResolver`;`handlers/common` 在 `RedirectModel` 之后应用 `MappedModel` 覆盖请求体,并按 `EchoMappedModel` 配置回显 `X-CCX-Mapped-Model`/`X-CCX-Original-Model`/`X-CCX-Mapping-Source` 响应头。三条件安全门控:`ModelMapping.AutoResolve==true`(**默认由死配置的 true 改为 false**)AND `RoutingMode ∈ {assist, auto}` AND 无 `KillSwitch`,三者任一不满足则所有新增代码路径行为与今天字节级一致;且仅 `AutoManaged` 渠道触发,手动配置渠道完全不受影响。对抗性复核发现并修复一个真实 critical bug:`scoreEndpointForKey` 用空 `channelUID` 计算 `EndpointUID` 并写入 `modelByUID`,而 `handlers` 层用真实 `channelUID` 查 `modelByUID`,两者哈希永不相等导致 `MappedModel` 在 key 级调度路径上完全查不到(提交 856372c4 修复:命中画像后改用 `profile.EndpointUID`,新增回归测试锁定该不变量)。至此 Phase 3B 四个子项(自动恢复探测、StabilityTier 滞后、模型自动映射、active filter 前置判定)全部完成;SLO regression 自动回滚仍是独立子系统,留待后续单独立项。\n\n**范围**:\n- [x] 运行时指标驱动画像实时更新(Phase 3A:`collectAll()` 每轮 diff + changelog + WebSocket 推送)\n- [x] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)—— Phase 3B-2(2026-07-09 完成,提交 0f02e988 + 856372c4)\n- [x] active model filter / context filter 支持自动映射前置判定 —— Phase 3B-2:`scheduler.ModelSupportResolverFunc` fail-open 接入 `select.go`\n- [x] 自动恢复探测(limited/dead → healthy)—— Phase 3B-1(2026-07-09 完成):`APIKeyResolver` + `carryForwardProbeFields` + `ConsecutiveProbeSuccess`/`ProbeRecoveryThreshold`\n- [x] 晋升/降级机制(连续成功→升级,连续失败→降级)—— Phase 3B-3(2026-07-09 完成,提交 664d3fff):`StabilityTier` 连续窗口滞后(`EffectiveStabilityTier`),暂不含 QualityTier/SpeedTier/CostTier\n- [x] WebSocket 推送画像变更事件(Phase 3A:`GET /api/health-center/events`)\n- [x] 前端画像变更历史/时间线(Phase 3A:`ProfileChangelogTimeline.vue`)\n\n**预估工期**:2-3 周(3A、3B-1、3B-2、3B-3 均已完成;SLO regression 自动回滚待单独立项评估工期)\n\n### Phase 4:高级特性\n\n**Phase 4 状态(2026-07-09,全部完成)**:8 项全部实现并接入真实链路,均遵循\"默认关闭 + 显式 opt-in\"安全守则。\n\n**范围**:\n- [x] 多维度标签系统扩展(用户自定义标签)——Item 1\n- [x] 成本报表:按用户/模型/渠道/key 统计真实有效成本——Item 2(新增 `proxyKeyMask` 身份贯穿链路:`middleware.GetProxyKeyMask` → `ChannelLog`/`RequestRecord` → SQLite `proxy_key_mask` 列,供 Item 4 复用)\n- [x] SLO regression 自动回滚——Item 3(默认关闭;`SLORollbackConfig`,连续 degrading 窗口数默认 3,不做自动恢复以避免震荡)\n- [x] 渠道推荐:根据使用模式推荐新渠道——Item 4\n- [x] 批量渠道管理(导入/导出/模板)——Item 5\n- [x] 订阅中心 provider adapter:可选自动刷新余额/套餐状态——Item 6(默认关闭;仅 openai/anthropic/google 且显式填写 BillingAPIKey 的订阅生效,中转/公益渠道继续手动维护)\n- [x] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板——Item 7\n- [x] A/B 测试:低比例统计抽样双发——Item 8(默认关闭,默认 1% 抽样 + 每小时 60 次硬预算;主请求路径不受影响,影子请求在主响应返回后异步发起)\n\n**关键实现说明**:\n- Item 3/6/8 均在 `AutopilotRoutingConfig` 新增独立配置块(`SLORollback`/`SubscriptionAutoRefresh`/`ABTest`),`Validate()` 统一做负值/越界兜底,保持与既有字段一致的防御模式。\n- Item 4 直接复用 Item 2 的 `proxyKeyMask` 身份标记,未重新发明身份识别机制。\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- ~~SQLite schema version 和幂等 migration。~~ **已完成**:`ensureSchemaVersion` 基于 `PRAGMA user_version`,当前 7 张表定版为 v1 基线;库版本高于代码版本时返回 error(fail-closed,不做任何写操作),交给下游 fail-open 分支接管。\n- ~~旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。~~ **已完成**:`ensureOriginBackfill` 只补 `\"unknown\"`,不做任何基于 URL/名称的猜测推断;已接线到 `Profiler.DeriveEndpointProfile`,`BreakTieByOriginTier` 现在能吃到真实数据(此前一直是 fallback 的 `unknown`)。\n- ~~profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。~~ **已确认满足并测试锁定**:`main.go` 现有结构本就是 fail-open(`NewProfileStore`/`NewManager` 出错只打日志、`autopilotManager` 保持 `nil`,下游全部有 `nil` 守卫),本次新增 `schema_migration_test.go` 用测试锁定这条不变量。\n- ~~全局 `killSwitch`、按 task class disable、按 channel disable。~~ **已完成**:新增 `AutopilotRoutingConfig.DisabledTaskClasses`/`DisabledChannelUIDs`;`CandidateFilterFor`/`executeFilter`/`BuildPlan` 三处一致接入,`smart_router_invariant_test.go` 覆盖\"task class 禁用后回退默认调度\"\"channel 禁用后不出现在候选里\"\"dry-run 与真实路径行为一致\"三条不变量。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。**待实现**:`TrustedRoutingAdvisor.promotionMode` 目前是 `manual`,shadow→candidate→active 已有人工审查关卡;自动回滚是独立的新子系统(需要基线捕获窗口 + 实时对比 + 阈值判定),留待下一轮单独设计落地。\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | shadow 样本/准确率/critical misroute 门槛;active 后 SLO regression 自动 rollback;全局 kill switch |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "3ee5fd77fab178573b8b1c85011f55a4dcfd32c4": {"t": 1785759374.439169, "n": 144572, "h": "1476342285d6047a", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.2.3 同订阅多 Key:能力共享与配额负载均衡\n\n**问题**:官方订阅 plan(Claude Max、Codex plan 等)允许用户持有多个 key,或购买多份相同套餐。若每个 endpoint 独立探测与配置,探测预算被浪费、配置重复;请求量大时还需要在同 plan 的多个 key 之间按官方 RPM 限速自动负载均衡,而不是打满第一个 key 才溢出到下一个。\n\n**能力检测去重(同一套餐只需一套能力检测和配置)**:\n\n```text\nSubscriptionUID 相同的 endpoint 共享一份「订阅级能力画像」:\n 模型列表 / vision / tool / reasoning 支持 / 协议兼容开关 → 只探测一次,\n 存在 SubscriptionProfile.SharedCapability,endpoint 画像以引用方式继承。\n\n仍然 per-endpoint 维护的:健康状态、延迟、熔断、用量窗口\n (key 可能被单独封禁/限速,能力相同不代表运行态相同)\n\n继承失效条件:某 key 实测模型列表哈希与订阅级不一致(GroupChangeDetector 信号)\n → 该 key 脱离继承、独立探测,并在 UI 标记「与套餐能力不一致」警告。\n```\n\n**配额负载均衡(Phase 1 shadow 展示,Phase 2 生效)**:\n\n```text\n每 key 维护配额余量视图:\n remainingRPM = officialRPM − 当前窗口已用\n officialRPM 来源优先级:内置 manifest > 用户配置 > RateLimitDiscoverer 建议\n\n同订阅多 key 时按「剩余余量加权」分配请求(余量大者优先、\n窗口最早重置者兜底),替代顺序打满:\n 仅当渠道 autoManaged 且同一 SubscriptionUID 下 key 数 > 1 时启用;\n 否则保持现有 sequential/random key 顺序策略不变。\n\n某 key 的窗口配额耗尽 → 临时降权并切到同订阅其他 key,\n不熔断整个渠道(这是配额耗尽,不是故障)。\n```\n\n### 3.2.4 订阅用量窗口 (Usage Windows)\n\n部分官方 plan 提供 5h / 日 / 周 / 月等窗口的用量查询与限制(如 Claude 订阅的 5h 滚动窗口 + 周配额)。在 key 的位置展示这些窗口,帮助用户掌握使用情况:\n\n```go\ntype UsageWindow struct {\n Window string `json:\"window\"` // \"5h\" | \"day\" | \"week\" | \"month\"\n Used float64 `json:\"used\"` // 已用量\n Limit float64 `json:\"limit\"` // 上限,0 = 未知\n Unit string `json:\"unit\"` // requests | tokens | credits | percent\n ResetsAt time.Time `json:\"resetsAt\"` // 窗口重置时间\n Source string `json:\"source\"` // official_api | response_header | local_metering\n FetchedAt time.Time `json:\"fetchedAt\"`\n}\n// KeyEndpointProfile 与 SubscriptionProfile 均增加 UsageWindows []UsageWindow\n```\n\n数据来源优先级:\n\n```text\n1. 官方用量 API:有 provider adapter 的 plan 主动拉取(§Phase 4 订阅 adapter 的可提前子集)\n2. 响应头:anthropic-ratelimit-* / x-ratelimit-* 等(复用 RateLimitDiscoverer 的信号 tap)\n3. 本地计量兜底:CCX 按 key 累计请求数/token(误差来自窗口起点对齐,标注 Source=local_metering)\n```\n\nUI 要求:key 行内显示主窗口(5h)用量进度条,悬浮展开全部窗口;用量 >80% 黄色预警、超限红色并显示预计重置时间。调度联动(Phase 2):窗口余量进入配额负载均衡权重,窗口耗尽的 key 临时降权而非熔断。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n#### 4.2.1 内置模型清单 (Builtin Models Manifest)\n\n**问题**:部分官方订阅 plan 的入口没有正确的 models 接口——要么不存在(404)、要么需要额外权限、要么返回与订阅实际可用模型不符的列表(典型:Claude 订阅 OAuth 入口、Codex plan 入口、部分官方企业网关)。对这些 baseURL 做模型发现会失败或产出错误画像。\n\n**方案**:CCX 内置「已知官方 baseURL → 模型清单」映射表,命中时直接返回内置内容,完全跳过上游 models 接口:\n\n```go\n// backend-go/internal/config/builtin_models_manifest.go(生成式,随模型注册表更新)\ntype BuiltinModelsManifest struct {\n BaseURLPattern string `json:\"baseUrlPattern\"` // 精确 host 或前缀匹配,如 \"api.anthropic.com\"\n ServiceType string `json:\"serviceType\"` // messages/responses/…\n PlanHint string `json:\"planHint,omitempty\"` // 订阅类型提示(如 \"claude_subscription\")\n ModelIDs []string `json:\"modelIds\"` // 该入口实际可用的模型清单\n DisableProbe bool `json:\"disableProbe\"` // true 时 Discovery 跳过 GET /v1/models\n}\n```\n\n**两个生效位置**:\n\n```text\n1. Discovery(§4.2 流程第 2 步之前):\n baseURL 命中 manifest 且 DisableProbe=true\n → 跳过 GET /v1/models,直接用 ModelIDs 生成 ModelProfile[]\n → ProfileSource = \"builtin_manifest\",不再因 models 接口失败而误判 misconfigured\n\n2. /v1/models 代理响应(现有 Models API):\n 客户端向 CCX 请求 /v1/models 时,聚合结果中该渠道的贡献\n 直接取内置清单,不向上游发起 models 请求\n (渠道显式配置了 supportedModels 时仍以用户配置优先)\n```\n\n**维护与覆盖顺序**:\n\n```text\n渠道显式 supportedModels(用户手动) > 内置 manifest > 上游 models 接口实探 > 内置候选模型回退\n```\n\nmanifest 与模型注册表同源生成(`generated_model_registry.go` 同级),新模型发布时随注册表一起更新;HealthAnalyzer 对命中 manifest 的 endpoint 不将「models 接口 404/403」计入 misconfigured 信号。\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法确定时 domainStrengthScore 一律取 0.5 中性值,该项对所有候选贡献相同、等效于不存在。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设 + 自定义\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n| `custom` | 用户自定义 | 用户自定义 | 高级用户微调,范围 0.0 ~ 3.0 |\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法判定,中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general(中性),domainStrengthScore 对所有候选取 0.5,该项失效。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选最佳匹配\n // 匹配优先级:\n // a. 同模型族(opus→opus, sonnet→sonnet)—— 最高优先\n // b. 同质量档(premium→premium)\n // c. 上下文窗口最接近(不超也不差太多)\n // d. 探测延迟最低\n best := rankBySimilarity(eligible, requestModel, floor)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**Phase 3A 状态(2026-07-09)**:低风险、不改真实路由语义的子集已完成并接入真实链路——`Manager.collectAll()` 新旧画像对比产出变更事件(`DetectProfileChanges`)、`ProfileChangelogStore`(环形内存 + SQLite,30 天机会性清理)、`EventHub` 内存 pub/sub、`GET /api/health-center/changelog`(历史)+ `GET /api/health-center/events`(WebSocket 实时推送)、`AutoDiscoveryRunner` 发布 `discovery_completed`/`auto_mapping_applied`、前端 `ProfileChangelogTimeline.vue` 接入健康中心页并支持断线自动重连。均为只读展示,未修改 `SmartRouter`/`EndpointAttemptPolicy` 调度逻辑。落地时顺带发现并修复一个真实缺口:浏览器原生 WebSocket 无法设自定义 header,为此给 `middleware.getAPIKey` 增加了 `Sec-WebSocket-Protocol` 鉴权回退(业界标准做法,仅新增一个 key 来源,不降低现有校验)。\n\n**Phase 3B(已全部完成,2026-07-09)**:模型自动映射、自动恢复探测、晋升/降级机制会直接改变真实调度决策,风险明显高于 3A,四个子项已分批全部完成。§12.2 P1.5 的 SQLite schema version / 版本化 migration / `OriginType`&`OriginTier` 旧配置 backfill / `killSwitch`+按 task class/channel disable **已于 2026-07-09 补强完成**(详见 §12.2 P1.5)。**Phase 3B-1(自动恢复探测补全)已于 2026-07-09 完成**:修复了 L2 探测子系统里此前从未打通的两个真实 bug——① `collectAll()` 每轮把新构造的画像整行 Upsert,会把 `ProbeWorker` 刚写入的 `LastProbeAt`/`ProbeSuccess`/`ConsecutiveProbeSuccess` 等字段清零,导致 6 小时探测冷却期形同虚设(修复:`carryForwardProbeFields` 在 Upsert 前搬运 Probe* 字段,不影响 L1 诊断字段的现有覆盖逻辑);② `extractRawAPIKey` 是返回 `KeyMask` 掩码值的占位 stub,探测请求实际带的是假 key(修复:新增 `APIKeyResolver` 回调 + `Manager.ResolveAPIKey`,通过遍历配置里对应渠道的 `APIKeys` 逐个计算 `KeyHashFromAPIKey` 比对来还原明文;resolver 未注入或未命中时 fail-open 跳过探测且不消耗预算)。同时补齐了 `degraded/limited → healthy` 的连续成功恢复路径(新增 `KeyEndpointProfile.ConsecutiveProbeSuccess` 计数器 + `HealthCheckConfig.ProbeRecoveryThreshold`,默认连续 2 次探测成功才恢复,避免单次探测噪声导致状态抖动;探测失败计数器清零)。**Phase 3B-3(StabilityTier 晋降级滞后)已于 2026-07-09 完成**(提交 664d3fff):在原本每轮从瞬时 1h 窗口重算的无记忆 `StabilityTier` 上加一层连续窗口滞后——新增 `KeyEndpointProfile.EffectiveStabilityTier`/`StabilityPendingTier`/`StabilityPendingStreak` 三字段 + 纯函数 `applyStabilityHysteresis`(对称阈值:连续 N 轮同值才切换生效档,`HealthCheckConfig.StabilityHysteresisWindows` 默认 2;首次画像直接采用,不等待阈值,避免新渠道卡在零值),`AggregateChannelProfile` 改读 `EffectiveStabilityTier`(空则回退 `StabilityTier`)。晋升/降级机制的数据模型仅限于 StabilityTier,不改变 QualityTier/SpeedTier/CostTier 语义。**Phase 3B-2(模型自动映射闭环)已于 2026-07-09 完成**(提交 0f02e988,随后修复一处 critical 集成 bug,提交 856372c4):新建 `ModelProfileStore`(SQLite,`model_profiles` 表)+ 扩展 `AutoDiscoveryRunner` 为每个发现模型写入 `ModelProfile`(仅 `AutoManaged` 渠道);新建 `ModelResolver` 实现 §5.4 五步逻辑(手动 `RedirectModel` 短路 → `GetModelProfiles` → `filterByCapabilityFloor` → `rankBySimilarity`);`scheduler` 包新增 `ModelSupportResolverFunc` hook(fail-open,未注入或返回不支持时严格回退到原 `ExplainModelSupport`);`endpoint_policy.go` 的 `resolveMappedModel`(此前是永不触发的死代码——`profile.ModelMapping` 从未被写入)现调用 `ModelResolver`;`handlers/common` 在 `RedirectModel` 之后应用 `MappedModel` 覆盖请求体,并按 `EchoMappedModel` 配置回显 `X-CCX-Mapped-Model`/`X-CCX-Original-Model`/`X-CCX-Mapping-Source` 响应头。三条件安全门控:`ModelMapping.AutoResolve==true`(**默认由死配置的 true 改为 false**)AND `RoutingMode ∈ {assist, auto}` AND 无 `KillSwitch`,三者任一不满足则所有新增代码路径行为与今天字节级一致;且仅 `AutoManaged` 渠道触发,手动配置渠道完全不受影响。对抗性复核发现并修复一个真实 critical bug:`scoreEndpointForKey` 用空 `channelUID` 计算 `EndpointUID` 并写入 `modelByUID`,而 `handlers` 层用真实 `channelUID` 查 `modelByUID`,两者哈希永不相等导致 `MappedModel` 在 key 级调度路径上完全查不到(提交 856372c4 修复:命中画像后改用 `profile.EndpointUID`,新增回归测试锁定该不变量)。至此 Phase 3B 四个子项(自动恢复探测、StabilityTier 滞后、模型自动映射、active filter 前置判定)全部完成;SLO regression 自动回滚仍是独立子系统,留待后续单独立项。\n\n**范围**:\n- [x] 运行时指标驱动画像实时更新(Phase 3A:`collectAll()` 每轮 diff + changelog + WebSocket 推送)\n- [x] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)—— Phase 3B-2(2026-07-09 完成,提交 0f02e988 + 856372c4)\n- [x] active model filter / context filter 支持自动映射前置判定 —— Phase 3B-2:`scheduler.ModelSupportResolverFunc` fail-open 接入 `select.go`\n- [x] 自动恢复探测(limited/dead → healthy)—— Phase 3B-1(2026-07-09 完成):`APIKeyResolver` + `carryForwardProbeFields` + `ConsecutiveProbeSuccess`/`ProbeRecoveryThreshold`\n- [x] 晋升/降级机制(连续成功→升级,连续失败→降级)—— Phase 3B-3(2026-07-09 完成,提交 664d3fff):`StabilityTier` 连续窗口滞后(`EffectiveStabilityTier`),暂不含 QualityTier/SpeedTier/CostTier\n- [x] WebSocket 推送画像变更事件(Phase 3A:`GET /api/health-center/events`)\n- [x] 前端画像变更历史/时间线(Phase 3A:`ProfileChangelogTimeline.vue`)\n\n**预估工期**:2-3 周(3A、3B-1、3B-2、3B-3 均已完成;SLO regression 自动回滚待单独立项评估工期)\n\n### Phase 4:高级特性\n\n**范围**:\n- [ ] 多维度标签系统扩展(用户自定义标签)\n- [ ] A/B 测试:同一请求走不同渠道对比\n- [ ] 成本报表:按用户/模型/渠道/key 统计真实有效成本\n- [ ] 订阅中心 provider adapter:可选自动刷新余额/套餐状态\n- [ ] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板\n- [ ] 批量渠道管理(导入/导出/模板)\n- [ ] 渠道推荐:根据使用模式推荐新渠道\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- ~~SQLite schema version 和幂等 migration。~~ **已完成**:`ensureSchemaVersion` 基于 `PRAGMA user_version`,当前 7 张表定版为 v1 基线;库版本高于代码版本时返回 error(fail-closed,不做任何写操作),交给下游 fail-open 分支接管。\n- ~~旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。~~ **已完成**:`ensureOriginBackfill` 只补 `\"unknown\"`,不做任何基于 URL/名称的猜测推断;已接线到 `Profiler.DeriveEndpointProfile`,`BreakTieByOriginTier` 现在能吃到真实数据(此前一直是 fallback 的 `unknown`)。\n- ~~profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。~~ **已确认满足并测试锁定**:`main.go` 现有结构本就是 fail-open(`NewProfileStore`/`NewManager` 出错只打日志、`autopilotManager` 保持 `nil`,下游全部有 `nil` 守卫),本次新增 `schema_migration_test.go` 用测试锁定这条不变量。\n- ~~全局 `killSwitch`、按 task class disable、按 channel disable。~~ **已完成**:新增 `AutopilotRoutingConfig.DisabledTaskClasses`/`DisabledChannelUIDs`;`CandidateFilterFor`/`executeFilter`/`BuildPlan` 三处一致接入,`smart_router_invariant_test.go` 覆盖\"task class 禁用后回退默认调度\"\"channel 禁用后不出现在候选里\"\"dry-run 与真实路径行为一致\"三条不变量。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。**待实现**:`TrustedRoutingAdvisor.promotionMode` 目前是 `manual`,shadow→candidate→active 已有人工审查关卡;自动回滚是独立的新子系统(需要基线捕获窗口 + 实时对比 + 阈值判定),留待下一轮单独设计落地。\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | shadow 样本/准确率/critical misroute 门槛;active 后 SLO regression 自动 rollback;全局 kill switch |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "833bb7b3686ce9276c4b51a128a631241854dcf8": {"t": 1785759377.7451577, "n": 142799, "h": "dfe7b40c1555eea5", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.2.3 同订阅多 Key:能力共享与配额负载均衡\n\n**问题**:官方订阅 plan(Claude Max、Codex plan 等)允许用户持有多个 key,或购买多份相同套餐。若每个 endpoint 独立探测与配置,探测预算被浪费、配置重复;请求量大时还需要在同 plan 的多个 key 之间按官方 RPM 限速自动负载均衡,而不是打满第一个 key 才溢出到下一个。\n\n**能力检测去重(同一套餐只需一套能力检测和配置)**:\n\n```text\nSubscriptionUID 相同的 endpoint 共享一份「订阅级能力画像」:\n 模型列表 / vision / tool / reasoning 支持 / 协议兼容开关 → 只探测一次,\n 存在 SubscriptionProfile.SharedCapability,endpoint 画像以引用方式继承。\n\n仍然 per-endpoint 维护的:健康状态、延迟、熔断、用量窗口\n (key 可能被单独封禁/限速,能力相同不代表运行态相同)\n\n继承失效条件:某 key 实测模型列表哈希与订阅级不一致(GroupChangeDetector 信号)\n → 该 key 脱离继承、独立探测,并在 UI 标记「与套餐能力不一致」警告。\n```\n\n**配额负载均衡(Phase 1 shadow 展示,Phase 2 生效)**:\n\n```text\n每 key 维护配额余量视图:\n remainingRPM = officialRPM − 当前窗口已用\n officialRPM 来源优先级:内置 manifest > 用户配置 > RateLimitDiscoverer 建议\n\n同订阅多 key 时按「剩余余量加权」分配请求(余量大者优先、\n窗口最早重置者兜底),替代顺序打满:\n 仅当渠道 autoManaged 且同一 SubscriptionUID 下 key 数 > 1 时启用;\n 否则保持现有 sequential/random key 顺序策略不变。\n\n某 key 的窗口配额耗尽 → 临时降权并切到同订阅其他 key,\n不熔断整个渠道(这是配额耗尽,不是故障)。\n```\n\n### 3.2.4 订阅用量窗口 (Usage Windows)\n\n部分官方 plan 提供 5h / 日 / 周 / 月等窗口的用量查询与限制(如 Claude 订阅的 5h 滚动窗口 + 周配额)。在 key 的位置展示这些窗口,帮助用户掌握使用情况:\n\n```go\ntype UsageWindow struct {\n Window string `json:\"window\"` // \"5h\" | \"day\" | \"week\" | \"month\"\n Used float64 `json:\"used\"` // 已用量\n Limit float64 `json:\"limit\"` // 上限,0 = 未知\n Unit string `json:\"unit\"` // requests | tokens | credits | percent\n ResetsAt time.Time `json:\"resetsAt\"` // 窗口重置时间\n Source string `json:\"source\"` // official_api | response_header | local_metering\n FetchedAt time.Time `json:\"fetchedAt\"`\n}\n// KeyEndpointProfile 与 SubscriptionProfile 均增加 UsageWindows []UsageWindow\n```\n\n数据来源优先级:\n\n```text\n1. 官方用量 API:有 provider adapter 的 plan 主动拉取(§Phase 4 订阅 adapter 的可提前子集)\n2. 响应头:anthropic-ratelimit-* / x-ratelimit-* 等(复用 RateLimitDiscoverer 的信号 tap)\n3. 本地计量兜底:CCX 按 key 累计请求数/token(误差来自窗口起点对齐,标注 Source=local_metering)\n```\n\nUI 要求:key 行内显示主窗口(5h)用量进度条,悬浮展开全部窗口;用量 >80% 黄色预警、超限红色并显示预计重置时间。调度联动(Phase 2):窗口余量进入配额负载均衡权重,窗口耗尽的 key 临时降权而非熔断。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n#### 4.2.1 内置模型清单 (Builtin Models Manifest)\n\n**问题**:部分官方订阅 plan 的入口没有正确的 models 接口——要么不存在(404)、要么需要额外权限、要么返回与订阅实际可用模型不符的列表(典型:Claude 订阅 OAuth 入口、Codex plan 入口、部分官方企业网关)。对这些 baseURL 做模型发现会失败或产出错误画像。\n\n**方案**:CCX 内置「已知官方 baseURL → 模型清单」映射表,命中时直接返回内置内容,完全跳过上游 models 接口:\n\n```go\n// backend-go/internal/config/builtin_models_manifest.go(生成式,随模型注册表更新)\ntype BuiltinModelsManifest struct {\n BaseURLPattern string `json:\"baseUrlPattern\"` // 精确 host 或前缀匹配,如 \"api.anthropic.com\"\n ServiceType string `json:\"serviceType\"` // messages/responses/…\n PlanHint string `json:\"planHint,omitempty\"` // 订阅类型提示(如 \"claude_subscription\")\n ModelIDs []string `json:\"modelIds\"` // 该入口实际可用的模型清单\n DisableProbe bool `json:\"disableProbe\"` // true 时 Discovery 跳过 GET /v1/models\n}\n```\n\n**两个生效位置**:\n\n```text\n1. Discovery(§4.2 流程第 2 步之前):\n baseURL 命中 manifest 且 DisableProbe=true\n → 跳过 GET /v1/models,直接用 ModelIDs 生成 ModelProfile[]\n → ProfileSource = \"builtin_manifest\",不再因 models 接口失败而误判 misconfigured\n\n2. /v1/models 代理响应(现有 Models API):\n 客户端向 CCX 请求 /v1/models 时,聚合结果中该渠道的贡献\n 直接取内置清单,不向上游发起 models 请求\n (渠道显式配置了 supportedModels 时仍以用户配置优先)\n```\n\n**维护与覆盖顺序**:\n\n```text\n渠道显式 supportedModels(用户手动) > 内置 manifest > 上游 models 接口实探 > 内置候选模型回退\n```\n\nmanifest 与模型注册表同源生成(`generated_model_registry.go` 同级),新模型发布时随注册表一起更新;HealthAnalyzer 对命中 manifest 的 endpoint 不将「models 接口 404/403」计入 misconfigured 信号。\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法确定时 domainStrengthScore 一律取 0.5 中性值,该项对所有候选贡献相同、等效于不存在。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设 + 自定义\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n| `custom` | 用户自定义 | 用户自定义 | 高级用户微调,范围 0.0 ~ 3.0 |\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法判定,中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general(中性),domainStrengthScore 对所有候选取 0.5,该项失效。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选最佳匹配\n // 匹配优先级:\n // a. 同模型族(opus→opus, sonnet→sonnet)—— 最高优先\n // b. 同质量档(premium→premium)\n // c. 上下文窗口最接近(不超也不差太多)\n // d. 探测延迟最低\n best := rankBySimilarity(eligible, requestModel, floor)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**Phase 3A 状态(2026-07-09)**:低风险、不改真实路由语义的子集已完成并接入真实链路——`Manager.collectAll()` 新旧画像对比产出变更事件(`DetectProfileChanges`)、`ProfileChangelogStore`(环形内存 + SQLite,30 天机会性清理)、`EventHub` 内存 pub/sub、`GET /api/health-center/changelog`(历史)+ `GET /api/health-center/events`(WebSocket 实时推送)、`AutoDiscoveryRunner` 发布 `discovery_completed`/`auto_mapping_applied`、前端 `ProfileChangelogTimeline.vue` 接入健康中心页并支持断线自动重连。均为只读展示,未修改 `SmartRouter`/`EndpointAttemptPolicy` 调度逻辑。落地时顺带发现并修复一个真实缺口:浏览器原生 WebSocket 无法设自定义 header,为此给 `middleware.getAPIKey` 增加了 `Sec-WebSocket-Protocol` 鉴权回退(业界标准做法,仅新增一个 key 来源,不降低现有校验)。\n\n**Phase 3B(部分启动)**:模型自动映射、自动恢复探测、晋升/降级机制会直接改变真实调度决策,风险明显高于 3A。§12.2 P1.5 的 SQLite schema version / 版本化 migration / `OriginType`&`OriginTier` 旧配置 backfill / `killSwitch`+按 task class/channel disable **已于 2026-07-09 补强完成**(详见 §12.2 P1.5)。**Phase 3B-1(自动恢复探测补全)已于 2026-07-09 完成**:修复了 L2 探测子系统里此前从未打通的两个真实 bug——① `collectAll()` 每轮把新构造的画像整行 Upsert,会把 `ProbeWorker` 刚写入的 `LastProbeAt`/`ProbeSuccess`/`ConsecutiveProbeSuccess` 等字段清零,导致 6 小时探测冷却期形同虚设(修复:`carryForwardProbeFields` 在 Upsert 前搬运 Probe* 字段,不影响 L1 诊断字段的现有覆盖逻辑);② `extractRawAPIKey` 是返回 `KeyMask` 掩码值的占位 stub,探测请求实际带的是假 key(修复:新增 `APIKeyResolver` 回调 + `Manager.ResolveAPIKey`,通过遍历配置里对应渠道的 `APIKeys` 逐个计算 `KeyHashFromAPIKey` 比对来还原明文;resolver 未注入或未命中时 fail-open 跳过探测且不消耗预算)。同时补齐了 `degraded/limited → healthy` 的连续成功恢复路径(新增 `KeyEndpointProfile.ConsecutiveProbeSuccess` 计数器 + `HealthCheckConfig.ProbeRecoveryThreshold`,默认连续 2 次探测成功才恢复,避免单次探测噪声导致状态抖动;探测失败计数器清零)。全程只做“让已声明的设计正确工作”,未新增调度语义,且 L2 探测默认关闭(`L2ProbeEnabled=false`),对未开启的用户零行为变化。模型自动映射、晋升/降级机制体量和风险仍明显更高,连同 SLO regression 自动回滚一起留待独立立项;启动前建议单独过一遍风险评审。\n\n**范围**:\n- [x] 运行时指标驱动画像实时更新(Phase 3A:`collectAll()` 每轮 diff + changelog + WebSocket 推送)\n- [ ] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)—— Phase 3B,待独立立项\n- [ ] active model filter / context filter 支持自动映射前置判定 —— Phase 3B,待独立立项\n- [x] 自动恢复探测(limited/dead → healthy)—— Phase 3B-1(2026-07-09 完成):`APIKeyResolver` + `carryForwardProbeFields` + `ConsecutiveProbeSuccess`/`ProbeRecoveryThreshold`\n- [ ] 晋升/降级机制(连续成功→升级,连续失败→降级)—— Phase 3B,与 SLO regression 自动回滚合并留待独立立项(同属\"根据历史表现自动调整调度权重\")\n- [x] WebSocket 推送画像变更事件(Phase 3A:`GET /api/health-center/events`)\n- [x] 前端画像变更历史/时间线(Phase 3A:`ProfileChangelogTimeline.vue`)\n\n**预估工期**:2-3 周(3A、3B-1 已完成;模型自动映射与晋升/降级机制待单独立项评估工期)\n\n### Phase 4:高级特性\n\n**范围**:\n- [ ] 多维度标签系统扩展(用户自定义标签)\n- [ ] A/B 测试:同一请求走不同渠道对比\n- [ ] 成本报表:按用户/模型/渠道/key 统计真实有效成本\n- [ ] 订阅中心 provider adapter:可选自动刷新余额/套餐状态\n- [ ] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板\n- [ ] 批量渠道管理(导入/导出/模板)\n- [ ] 渠道推荐:根据使用模式推荐新渠道\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- ~~SQLite schema version 和幂等 migration。~~ **已完成**:`ensureSchemaVersion` 基于 `PRAGMA user_version`,当前 7 张表定版为 v1 基线;库版本高于代码版本时返回 error(fail-closed,不做任何写操作),交给下游 fail-open 分支接管。\n- ~~旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。~~ **已完成**:`ensureOriginBackfill` 只补 `\"unknown\"`,不做任何基于 URL/名称的猜测推断;已接线到 `Profiler.DeriveEndpointProfile`,`BreakTieByOriginTier` 现在能吃到真实数据(此前一直是 fallback 的 `unknown`)。\n- ~~profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。~~ **已确认满足并测试锁定**:`main.go` 现有结构本就是 fail-open(`NewProfileStore`/`NewManager` 出错只打日志、`autopilotManager` 保持 `nil`,下游全部有 `nil` 守卫),本次新增 `schema_migration_test.go` 用测试锁定这条不变量。\n- ~~全局 `killSwitch`、按 task class disable、按 channel disable。~~ **已完成**:新增 `AutopilotRoutingConfig.DisabledTaskClasses`/`DisabledChannelUIDs`;`CandidateFilterFor`/`executeFilter`/`BuildPlan` 三处一致接入,`smart_router_invariant_test.go` 覆盖\"task class 禁用后回退默认调度\"\"channel 禁用后不出现在候选里\"\"dry-run 与真实路径行为一致\"三条不变量。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。**待实现**:`TrustedRoutingAdvisor.promotionMode` 目前是 `manual`,shadow→candidate→active 已有人工审查关卡;自动回滚是独立的新子系统(需要基线捕获窗口 + 实时对比 + 阈值判定),留待下一轮单独设计落地。\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | shadow 样本/准确率/critical misroute 门槛;active 后 SLO regression 自动 rollback;全局 kill switch |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "7ff1e1f9a68c0c14c3a8dbdec717aa79a0808ee2": {"t": 1785759380.0974894, "n": 141877, "h": "3c709f5b0da9c1b8", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.2.3 同订阅多 Key:能力共享与配额负载均衡\n\n**问题**:官方订阅 plan(Claude Max、Codex plan 等)允许用户持有多个 key,或购买多份相同套餐。若每个 endpoint 独立探测与配置,探测预算被浪费、配置重复;请求量大时还需要在同 plan 的多个 key 之间按官方 RPM 限速自动负载均衡,而不是打满第一个 key 才溢出到下一个。\n\n**能力检测去重(同一套餐只需一套能力检测和配置)**:\n\n```text\nSubscriptionUID 相同的 endpoint 共享一份「订阅级能力画像」:\n 模型列表 / vision / tool / reasoning 支持 / 协议兼容开关 → 只探测一次,\n 存在 SubscriptionProfile.SharedCapability,endpoint 画像以引用方式继承。\n\n仍然 per-endpoint 维护的:健康状态、延迟、熔断、用量窗口\n (key 可能被单独封禁/限速,能力相同不代表运行态相同)\n\n继承失效条件:某 key 实测模型列表哈希与订阅级不一致(GroupChangeDetector 信号)\n → 该 key 脱离继承、独立探测,并在 UI 标记「与套餐能力不一致」警告。\n```\n\n**配额负载均衡(Phase 1 shadow 展示,Phase 2 生效)**:\n\n```text\n每 key 维护配额余量视图:\n remainingRPM = officialRPM − 当前窗口已用\n officialRPM 来源优先级:内置 manifest > 用户配置 > RateLimitDiscoverer 建议\n\n同订阅多 key 时按「剩余余量加权」分配请求(余量大者优先、\n窗口最早重置者兜底),替代顺序打满:\n 仅当渠道 autoManaged 且同一 SubscriptionUID 下 key 数 > 1 时启用;\n 否则保持现有 sequential/random key 顺序策略不变。\n\n某 key 的窗口配额耗尽 → 临时降权并切到同订阅其他 key,\n不熔断整个渠道(这是配额耗尽,不是故障)。\n```\n\n### 3.2.4 订阅用量窗口 (Usage Windows)\n\n部分官方 plan 提供 5h / 日 / 周 / 月等窗口的用量查询与限制(如 Claude 订阅的 5h 滚动窗口 + 周配额)。在 key 的位置展示这些窗口,帮助用户掌握使用情况:\n\n```go\ntype UsageWindow struct {\n Window string `json:\"window\"` // \"5h\" | \"day\" | \"week\" | \"month\"\n Used float64 `json:\"used\"` // 已用量\n Limit float64 `json:\"limit\"` // 上限,0 = 未知\n Unit string `json:\"unit\"` // requests | tokens | credits | percent\n ResetsAt time.Time `json:\"resetsAt\"` // 窗口重置时间\n Source string `json:\"source\"` // official_api | response_header | local_metering\n FetchedAt time.Time `json:\"fetchedAt\"`\n}\n// KeyEndpointProfile 与 SubscriptionProfile 均增加 UsageWindows []UsageWindow\n```\n\n数据来源优先级:\n\n```text\n1. 官方用量 API:有 provider adapter 的 plan 主动拉取(§Phase 4 订阅 adapter 的可提前子集)\n2. 响应头:anthropic-ratelimit-* / x-ratelimit-* 等(复用 RateLimitDiscoverer 的信号 tap)\n3. 本地计量兜底:CCX 按 key 累计请求数/token(误差来自窗口起点对齐,标注 Source=local_metering)\n```\n\nUI 要求:key 行内显示主窗口(5h)用量进度条,悬浮展开全部窗口;用量 >80% 黄色预警、超限红色并显示预计重置时间。调度联动(Phase 2):窗口余量进入配额负载均衡权重,窗口耗尽的 key 临时降权而非熔断。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n#### 4.2.1 内置模型清单 (Builtin Models Manifest)\n\n**问题**:部分官方订阅 plan 的入口没有正确的 models 接口——要么不存在(404)、要么需要额外权限、要么返回与订阅实际可用模型不符的列表(典型:Claude 订阅 OAuth 入口、Codex plan 入口、部分官方企业网关)。对这些 baseURL 做模型发现会失败或产出错误画像。\n\n**方案**:CCX 内置「已知官方 baseURL → 模型清单」映射表,命中时直接返回内置内容,完全跳过上游 models 接口:\n\n```go\n// backend-go/internal/config/builtin_models_manifest.go(生成式,随模型注册表更新)\ntype BuiltinModelsManifest struct {\n BaseURLPattern string `json:\"baseUrlPattern\"` // 精确 host 或前缀匹配,如 \"api.anthropic.com\"\n ServiceType string `json:\"serviceType\"` // messages/responses/…\n PlanHint string `json:\"planHint,omitempty\"` // 订阅类型提示(如 \"claude_subscription\")\n ModelIDs []string `json:\"modelIds\"` // 该入口实际可用的模型清单\n DisableProbe bool `json:\"disableProbe\"` // true 时 Discovery 跳过 GET /v1/models\n}\n```\n\n**两个生效位置**:\n\n```text\n1. Discovery(§4.2 流程第 2 步之前):\n baseURL 命中 manifest 且 DisableProbe=true\n → 跳过 GET /v1/models,直接用 ModelIDs 生成 ModelProfile[]\n → ProfileSource = \"builtin_manifest\",不再因 models 接口失败而误判 misconfigured\n\n2. /v1/models 代理响应(现有 Models API):\n 客户端向 CCX 请求 /v1/models 时,聚合结果中该渠道的贡献\n 直接取内置清单,不向上游发起 models 请求\n (渠道显式配置了 supportedModels 时仍以用户配置优先)\n```\n\n**维护与覆盖顺序**:\n\n```text\n渠道显式 supportedModels(用户手动) > 内置 manifest > 上游 models 接口实探 > 内置候选模型回退\n```\n\nmanifest 与模型注册表同源生成(`generated_model_registry.go` 同级),新模型发布时随注册表一起更新;HealthAnalyzer 对命中 manifest 的 endpoint 不将「models 接口 404/403」计入 misconfigured 信号。\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法确定时 domainStrengthScore 一律取 0.5 中性值,该项对所有候选贡献相同、等效于不存在。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设 + 自定义\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n| `custom` | 用户自定义 | 用户自定义 | 高级用户微调,范围 0.0 ~ 3.0 |\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法判定,中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general(中性),domainStrengthScore 对所有候选取 0.5,该项失效。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选最佳匹配\n // 匹配优先级:\n // a. 同模型族(opus→opus, sonnet→sonnet)—— 最高优先\n // b. 同质量档(premium→premium)\n // c. 上下文窗口最接近(不超也不差太多)\n // d. 探测延迟最低\n best := rankBySimilarity(eligible, requestModel, floor)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**Phase 3A 状态(2026-07-09)**:低风险、不改真实路由语义的子集已完成并接入真实链路——`Manager.collectAll()` 新旧画像对比产出变更事件(`DetectProfileChanges`)、`ProfileChangelogStore`(环形内存 + SQLite,30 天机会性清理)、`EventHub` 内存 pub/sub、`GET /api/health-center/changelog`(历史)+ `GET /api/health-center/events`(WebSocket 实时推送)、`AutoDiscoveryRunner` 发布 `discovery_completed`/`auto_mapping_applied`、前端 `ProfileChangelogTimeline.vue` 接入健康中心页并支持断线自动重连。均为只读展示,未修改 `SmartRouter`/`EndpointAttemptPolicy` 调度逻辑。落地时顺带发现并修复一个真实缺口:浏览器原生 WebSocket 无法设自定义 header,为此给 `middleware.getAPIKey` 增加了 `Sec-WebSocket-Protocol` 鉴权回退(业界标准做法,仅新增一个 key 来源,不降低现有校验)。\n\n**Phase 3B(暂缓)**:模型自动映射、自动恢复探测、晋升/降级机制会直接改变真实调度决策,风险明显高于 3A。§12.2 P1.5 的 SQLite schema version / 版本化 migration / `OriginType`&`OriginTier` 旧配置 backfill / `killSwitch`+按 task class/channel disable **已于 2026-07-09 补强完成**(详见 §12.2 P1.5);仅剩 SLO regression 自动 rollback 待独立设计,不阻塞 3B 启动评估,但启动前仍建议对 3B 涉及的调度语义变更单独过一遍风险评审。\n\n**范围**:\n- [x] 运行时指标驱动画像实时更新(Phase 3A:`collectAll()` 每轮 diff + changelog + WebSocket 推送)\n- [ ] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)—— Phase 3B\n- [ ] active model filter / context filter 支持自动映射前置判定 —— Phase 3B\n- [ ] 自动恢复探测(limited/dead → healthy)—— Phase 3B\n- [ ] 晋升/降级机制(连续成功→升级,连续失败→降级)—— Phase 3B\n- [x] WebSocket 推送画像变更事件(Phase 3A:`GET /api/health-center/events`)\n- [x] 前端画像变更历史/时间线(Phase 3A:`ProfileChangelogTimeline.vue`)\n\n**预估工期**:2-3 周(3A 已完成;P1.5 迁移契约已补强,3B 待单独立项评估工期)\n\n### Phase 4:高级特性\n\n**范围**:\n- [ ] 多维度标签系统扩展(用户自定义标签)\n- [ ] A/B 测试:同一请求走不同渠道对比\n- [ ] 成本报表:按用户/模型/渠道/key 统计真实有效成本\n- [ ] 订阅中心 provider adapter:可选自动刷新余额/套餐状态\n- [ ] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板\n- [ ] 批量渠道管理(导入/导出/模板)\n- [ ] 渠道推荐:根据使用模式推荐新渠道\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- ~~SQLite schema version 和幂等 migration。~~ **已完成**:`ensureSchemaVersion` 基于 `PRAGMA user_version`,当前 7 张表定版为 v1 基线;库版本高于代码版本时返回 error(fail-closed,不做任何写操作),交给下游 fail-open 分支接管。\n- ~~旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。~~ **已完成**:`ensureOriginBackfill` 只补 `\"unknown\"`,不做任何基于 URL/名称的猜测推断;已接线到 `Profiler.DeriveEndpointProfile`,`BreakTieByOriginTier` 现在能吃到真实数据(此前一直是 fallback 的 `unknown`)。\n- ~~profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。~~ **已确认满足并测试锁定**:`main.go` 现有结构本就是 fail-open(`NewProfileStore`/`NewManager` 出错只打日志、`autopilotManager` 保持 `nil`,下游全部有 `nil` 守卫),本次新增 `schema_migration_test.go` 用测试锁定这条不变量。\n- ~~全局 `killSwitch`、按 task class disable、按 channel disable。~~ **已完成**:新增 `AutopilotRoutingConfig.DisabledTaskClasses`/`DisabledChannelUIDs`;`CandidateFilterFor`/`executeFilter`/`BuildPlan` 三处一致接入,`smart_router_invariant_test.go` 覆盖\"task class 禁用后回退默认调度\"\"channel 禁用后不出现在候选里\"\"dry-run 与真实路径行为一致\"三条不变量。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。**待实现**:`TrustedRoutingAdvisor.promotionMode` 目前是 `manual`,shadow→candidate→active 已有人工审查关卡;自动回滚是独立的新子系统(需要基线捕获窗口 + 实时对比 + 阈值判定),留待下一轮单独设计落地。\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | shadow 样本/准确率/critical misroute 门槛;active 后 SLO regression 自动 rollback;全局 kill switch |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "bee4e48f273e738945b9cd96f16158e6deddda00": {"t": 1785759381.8635433, "n": 140889, "h": "40e5f73c6e8229bd", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.2.3 同订阅多 Key:能力共享与配额负载均衡\n\n**问题**:官方订阅 plan(Claude Max、Codex plan 等)允许用户持有多个 key,或购买多份相同套餐。若每个 endpoint 独立探测与配置,探测预算被浪费、配置重复;请求量大时还需要在同 plan 的多个 key 之间按官方 RPM 限速自动负载均衡,而不是打满第一个 key 才溢出到下一个。\n\n**能力检测去重(同一套餐只需一套能力检测和配置)**:\n\n```text\nSubscriptionUID 相同的 endpoint 共享一份「订阅级能力画像」:\n 模型列表 / vision / tool / reasoning 支持 / 协议兼容开关 → 只探测一次,\n 存在 SubscriptionProfile.SharedCapability,endpoint 画像以引用方式继承。\n\n仍然 per-endpoint 维护的:健康状态、延迟、熔断、用量窗口\n (key 可能被单独封禁/限速,能力相同不代表运行态相同)\n\n继承失效条件:某 key 实测模型列表哈希与订阅级不一致(GroupChangeDetector 信号)\n → 该 key 脱离继承、独立探测,并在 UI 标记「与套餐能力不一致」警告。\n```\n\n**配额负载均衡(Phase 1 shadow 展示,Phase 2 生效)**:\n\n```text\n每 key 维护配额余量视图:\n remainingRPM = officialRPM − 当前窗口已用\n officialRPM 来源优先级:内置 manifest > 用户配置 > RateLimitDiscoverer 建议\n\n同订阅多 key 时按「剩余余量加权」分配请求(余量大者优先、\n窗口最早重置者兜底),替代顺序打满:\n 仅当渠道 autoManaged 且同一 SubscriptionUID 下 key 数 > 1 时启用;\n 否则保持现有 sequential/random key 顺序策略不变。\n\n某 key 的窗口配额耗尽 → 临时降权并切到同订阅其他 key,\n不熔断整个渠道(这是配额耗尽,不是故障)。\n```\n\n### 3.2.4 订阅用量窗口 (Usage Windows)\n\n部分官方 plan 提供 5h / 日 / 周 / 月等窗口的用量查询与限制(如 Claude 订阅的 5h 滚动窗口 + 周配额)。在 key 的位置展示这些窗口,帮助用户掌握使用情况:\n\n```go\ntype UsageWindow struct {\n Window string `json:\"window\"` // \"5h\" | \"day\" | \"week\" | \"month\"\n Used float64 `json:\"used\"` // 已用量\n Limit float64 `json:\"limit\"` // 上限,0 = 未知\n Unit string `json:\"unit\"` // requests | tokens | credits | percent\n ResetsAt time.Time `json:\"resetsAt\"` // 窗口重置时间\n Source string `json:\"source\"` // official_api | response_header | local_metering\n FetchedAt time.Time `json:\"fetchedAt\"`\n}\n// KeyEndpointProfile 与 SubscriptionProfile 均增加 UsageWindows []UsageWindow\n```\n\n数据来源优先级:\n\n```text\n1. 官方用量 API:有 provider adapter 的 plan 主动拉取(§Phase 4 订阅 adapter 的可提前子集)\n2. 响应头:anthropic-ratelimit-* / x-ratelimit-* 等(复用 RateLimitDiscoverer 的信号 tap)\n3. 本地计量兜底:CCX 按 key 累计请求数/token(误差来自窗口起点对齐,标注 Source=local_metering)\n```\n\nUI 要求:key 行内显示主窗口(5h)用量进度条,悬浮展开全部窗口;用量 >80% 黄色预警、超限红色并显示预计重置时间。调度联动(Phase 2):窗口余量进入配额负载均衡权重,窗口耗尽的 key 临时降权而非熔断。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n#### 4.2.1 内置模型清单 (Builtin Models Manifest)\n\n**问题**:部分官方订阅 plan 的入口没有正确的 models 接口——要么不存在(404)、要么需要额外权限、要么返回与订阅实际可用模型不符的列表(典型:Claude 订阅 OAuth 入口、Codex plan 入口、部分官方企业网关)。对这些 baseURL 做模型发现会失败或产出错误画像。\n\n**方案**:CCX 内置「已知官方 baseURL → 模型清单」映射表,命中时直接返回内置内容,完全跳过上游 models 接口:\n\n```go\n// backend-go/internal/config/builtin_models_manifest.go(生成式,随模型注册表更新)\ntype BuiltinModelsManifest struct {\n BaseURLPattern string `json:\"baseUrlPattern\"` // 精确 host 或前缀匹配,如 \"api.anthropic.com\"\n ServiceType string `json:\"serviceType\"` // messages/responses/…\n PlanHint string `json:\"planHint,omitempty\"` // 订阅类型提示(如 \"claude_subscription\")\n ModelIDs []string `json:\"modelIds\"` // 该入口实际可用的模型清单\n DisableProbe bool `json:\"disableProbe\"` // true 时 Discovery 跳过 GET /v1/models\n}\n```\n\n**两个生效位置**:\n\n```text\n1. Discovery(§4.2 流程第 2 步之前):\n baseURL 命中 manifest 且 DisableProbe=true\n → 跳过 GET /v1/models,直接用 ModelIDs 生成 ModelProfile[]\n → ProfileSource = \"builtin_manifest\",不再因 models 接口失败而误判 misconfigured\n\n2. /v1/models 代理响应(现有 Models API):\n 客户端向 CCX 请求 /v1/models 时,聚合结果中该渠道的贡献\n 直接取内置清单,不向上游发起 models 请求\n (渠道显式配置了 supportedModels 时仍以用户配置优先)\n```\n\n**维护与覆盖顺序**:\n\n```text\n渠道显式 supportedModels(用户手动) > 内置 manifest > 上游 models 接口实探 > 内置候选模型回退\n```\n\nmanifest 与模型注册表同源生成(`generated_model_registry.go` 同级),新模型发布时随注册表一起更新;HealthAnalyzer 对命中 manifest 的 endpoint 不将「models 接口 404/403」计入 misconfigured 信号。\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法确定时 domainStrengthScore 一律取 0.5 中性值,该项对所有候选贡献相同、等效于不存在。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设 + 自定义\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n| `custom` | 用户自定义 | 用户自定义 | 高级用户微调,范围 0.0 ~ 3.0 |\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法判定,中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general(中性),domainStrengthScore 对所有候选取 0.5,该项失效。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选最佳匹配\n // 匹配优先级:\n // a. 同模型族(opus→opus, sonnet→sonnet)—— 最高优先\n // b. 同质量档(premium→premium)\n // c. 上下文窗口最接近(不超也不差太多)\n // d. 探测延迟最低\n best := rankBySimilarity(eligible, requestModel, floor)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**Phase 3A 状态(2026-07-09)**:低风险、不改真实路由语义的子集已完成并接入真实链路——`Manager.collectAll()` 新旧画像对比产出变更事件(`DetectProfileChanges`)、`ProfileChangelogStore`(环形内存 + SQLite,30 天机会性清理)、`EventHub` 内存 pub/sub、`GET /api/health-center/changelog`(历史)+ `GET /api/health-center/events`(WebSocket 实时推送)、`AutoDiscoveryRunner` 发布 `discovery_completed`/`auto_mapping_applied`、前端 `ProfileChangelogTimeline.vue` 接入健康中心页并支持断线自动重连。均为只读展示,未修改 `SmartRouter`/`EndpointAttemptPolicy` 调度逻辑。落地时顺带发现并修复一个真实缺口:浏览器原生 WebSocket 无法设自定义 header,为此给 `middleware.getAPIKey` 增加了 `Sec-WebSocket-Protocol` 鉴权回退(业界标准做法,仅新增一个 key 来源,不降低现有校验)。\n\n**Phase 3B(暂缓)**:模型自动映射、自动恢复探测、晋升/降级机制会直接改变真实调度决策,风险明显高于 3A;且依赖 §12.2 P1.5 的 SQLite schema version / 版本化 migration / `OriginType`&`OriginTier` 旧配置 backfill 尚未补强,建议先完成 P1.5 契约再启动。\n\n**范围**:\n- [x] 运行时指标驱动画像实时更新(Phase 3A:`collectAll()` 每轮 diff + changelog + WebSocket 推送)\n- [ ] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)—— Phase 3B,待 P1.5 补强后再评估\n- [ ] active model filter / context filter 支持自动映射前置判定 —— Phase 3B\n- [ ] 自动恢复探测(limited/dead → healthy)—— Phase 3B\n- [ ] 晋升/降级机制(连续成功→升级,连续失败→降级)—— Phase 3B\n- [x] WebSocket 推送画像变更事件(Phase 3A:`GET /api/health-center/events`)\n- [x] 前端画像变更历史/时间线(Phase 3A:`ProfileChangelogTimeline.vue`)\n\n**预估工期**:2-3 周(3A 已完成;3B 待 P1.5 就绪后重新评估工期)\n\n### Phase 4:高级特性\n\n**范围**:\n- [ ] 多维度标签系统扩展(用户自定义标签)\n- [ ] A/B 测试:同一请求走不同渠道对比\n- [ ] 成本报表:按用户/模型/渠道/key 统计真实有效成本\n- [ ] 订阅中心 provider adapter:可选自动刷新余额/套餐状态\n- [ ] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板\n- [ ] 批量渠道管理(导入/导出/模板)\n- [ ] 渠道推荐:根据使用模式推荐新渠道\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- SQLite schema version 和幂等 migration。\n- 旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。\n- profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。\n- 全局 `killSwitch`、按 task class disable、按 channel disable。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | shadow 样本/准确率/critical misroute 门槛;active 后 SLO regression 自动 rollback;全局 kill switch |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "6cbaf9735ec3915a17221bb4ed30e8f7f56408ea": {"t": 1785759383.767946, "n": 139891, "h": "acffa56fdcb90a46", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.2.3 同订阅多 Key:能力共享与配额负载均衡\n\n**问题**:官方订阅 plan(Claude Max、Codex plan 等)允许用户持有多个 key,或购买多份相同套餐。若每个 endpoint 独立探测与配置,探测预算被浪费、配置重复;请求量大时还需要在同 plan 的多个 key 之间按官方 RPM 限速自动负载均衡,而不是打满第一个 key 才溢出到下一个。\n\n**能力检测去重(同一套餐只需一套能力检测和配置)**:\n\n```text\nSubscriptionUID 相同的 endpoint 共享一份「订阅级能力画像」:\n 模型列表 / vision / tool / reasoning 支持 / 协议兼容开关 → 只探测一次,\n 存在 SubscriptionProfile.SharedCapability,endpoint 画像以引用方式继承。\n\n仍然 per-endpoint 维护的:健康状态、延迟、熔断、用量窗口\n (key 可能被单独封禁/限速,能力相同不代表运行态相同)\n\n继承失效条件:某 key 实测模型列表哈希与订阅级不一致(GroupChangeDetector 信号)\n → 该 key 脱离继承、独立探测,并在 UI 标记「与套餐能力不一致」警告。\n```\n\n**配额负载均衡(Phase 1 shadow 展示,Phase 2 生效)**:\n\n```text\n每 key 维护配额余量视图:\n remainingRPM = officialRPM − 当前窗口已用\n officialRPM 来源优先级:内置 manifest > 用户配置 > RateLimitDiscoverer 建议\n\n同订阅多 key 时按「剩余余量加权」分配请求(余量大者优先、\n窗口最早重置者兜底),替代顺序打满:\n 仅当渠道 autoManaged 且同一 SubscriptionUID 下 key 数 > 1 时启用;\n 否则保持现有 sequential/random key 顺序策略不变。\n\n某 key 的窗口配额耗尽 → 临时降权并切到同订阅其他 key,\n不熔断整个渠道(这是配额耗尽,不是故障)。\n```\n\n### 3.2.4 订阅用量窗口 (Usage Windows)\n\n部分官方 plan 提供 5h / 日 / 周 / 月等窗口的用量查询与限制(如 Claude 订阅的 5h 滚动窗口 + 周配额)。在 key 的位置展示这些窗口,帮助用户掌握使用情况:\n\n```go\ntype UsageWindow struct {\n Window string `json:\"window\"` // \"5h\" | \"day\" | \"week\" | \"month\"\n Used float64 `json:\"used\"` // 已用量\n Limit float64 `json:\"limit\"` // 上限,0 = 未知\n Unit string `json:\"unit\"` // requests | tokens | credits | percent\n ResetsAt time.Time `json:\"resetsAt\"` // 窗口重置时间\n Source string `json:\"source\"` // official_api | response_header | local_metering\n FetchedAt time.Time `json:\"fetchedAt\"`\n}\n// KeyEndpointProfile 与 SubscriptionProfile 均增加 UsageWindows []UsageWindow\n```\n\n数据来源优先级:\n\n```text\n1. 官方用量 API:有 provider adapter 的 plan 主动拉取(§Phase 4 订阅 adapter 的可提前子集)\n2. 响应头:anthropic-ratelimit-* / x-ratelimit-* 等(复用 RateLimitDiscoverer 的信号 tap)\n3. 本地计量兜底:CCX 按 key 累计请求数/token(误差来自窗口起点对齐,标注 Source=local_metering)\n```\n\nUI 要求:key 行内显示主窗口(5h)用量进度条,悬浮展开全部窗口;用量 >80% 黄色预警、超限红色并显示预计重置时间。调度联动(Phase 2):窗口余量进入配额负载均衡权重,窗口耗尽的 key 临时降权而非熔断。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n#### 4.2.1 内置模型清单 (Builtin Models Manifest)\n\n**问题**:部分官方订阅 plan 的入口没有正确的 models 接口——要么不存在(404)、要么需要额外权限、要么返回与订阅实际可用模型不符的列表(典型:Claude 订阅 OAuth 入口、Codex plan 入口、部分官方企业网关)。对这些 baseURL 做模型发现会失败或产出错误画像。\n\n**方案**:CCX 内置「已知官方 baseURL → 模型清单」映射表,命中时直接返回内置内容,完全跳过上游 models 接口:\n\n```go\n// backend-go/internal/config/builtin_models_manifest.go(生成式,随模型注册表更新)\ntype BuiltinModelsManifest struct {\n BaseURLPattern string `json:\"baseUrlPattern\"` // 精确 host 或前缀匹配,如 \"api.anthropic.com\"\n ServiceType string `json:\"serviceType\"` // messages/responses/…\n PlanHint string `json:\"planHint,omitempty\"` // 订阅类型提示(如 \"claude_subscription\")\n ModelIDs []string `json:\"modelIds\"` // 该入口实际可用的模型清单\n DisableProbe bool `json:\"disableProbe\"` // true 时 Discovery 跳过 GET /v1/models\n}\n```\n\n**两个生效位置**:\n\n```text\n1. Discovery(§4.2 流程第 2 步之前):\n baseURL 命中 manifest 且 DisableProbe=true\n → 跳过 GET /v1/models,直接用 ModelIDs 生成 ModelProfile[]\n → ProfileSource = \"builtin_manifest\",不再因 models 接口失败而误判 misconfigured\n\n2. /v1/models 代理响应(现有 Models API):\n 客户端向 CCX 请求 /v1/models 时,聚合结果中该渠道的贡献\n 直接取内置清单,不向上游发起 models 请求\n (渠道显式配置了 supportedModels 时仍以用户配置优先)\n```\n\n**维护与覆盖顺序**:\n\n```text\n渠道显式 supportedModels(用户手动) > 内置 manifest > 上游 models 接口实探 > 内置候选模型回退\n```\n\nmanifest 与模型注册表同源生成(`generated_model_registry.go` 同级),新模型发布时随注册表一起更新;HealthAnalyzer 对命中 manifest 的 endpoint 不将「models 接口 404/403」计入 misconfigured 信号。\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法确定时 domainStrengthScore 一律取 0.5 中性值,该项对所有候选贡献相同、等效于不存在。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设 + 自定义\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n| `custom` | 用户自定义 | 用户自定义 | 高级用户微调,范围 0.0 ~ 3.0 |\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法判定,中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general(中性),domainStrengthScore 对所有候选取 0.5,该项失效。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选最佳匹配\n // 匹配优先级:\n // a. 同模型族(opus→opus, sonnet→sonnet)—— 最高优先\n // b. 同质量档(premium→premium)\n // c. 上下文窗口最接近(不超也不差太多)\n // d. 探测延迟最低\n best := rankBySimilarity(eligible, requestModel, floor)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**范围**:\n- [ ] 运行时指标驱动画像实时更新\n- [ ] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)\n- [ ] active model filter / context filter 支持自动映射前置判定\n- [ ] 自动恢复探测(limited/dead → healthy)\n- [ ] 晋升/降级机制(连续成功→升级,连续失败→降级)\n- [ ] WebSocket 推送画像变更事件\n- [ ] 前端画像变更历史/时间线\n\n**预估工期**:2-3 周\n\n### Phase 4:高级特性\n\n**范围**:\n- [ ] 多维度标签系统扩展(用户自定义标签)\n- [ ] A/B 测试:同一请求走不同渠道对比\n- [ ] 成本报表:按用户/模型/渠道/key 统计真实有效成本\n- [ ] 订阅中心 provider adapter:可选自动刷新余额/套餐状态\n- [ ] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板\n- [ ] 批量渠道管理(导入/导出/模板)\n- [ ] 渠道推荐:根据使用模式推荐新渠道\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- SQLite schema version 和幂等 migration。\n- 旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。\n- profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。\n- 全局 `killSwitch`、按 task class disable、按 channel disable。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | shadow 样本/准确率/critical misroute 门槛;active 后 SLO regression 自动 rollback;全局 kill switch |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "de2e9d2b4db3a671929aae512c81c44df3d0c9b8": {"t": 1785759385.6754093, "n": 137890, "h": "b0374886ab6accfa", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n#### 4.2.1 内置模型清单 (Builtin Models Manifest)\n\n**问题**:部分官方订阅 plan 的入口没有正确的 models 接口——要么不存在(404)、要么需要额外权限、要么返回与订阅实际可用模型不符的列表(典型:Claude 订阅 OAuth 入口、Codex plan 入口、部分官方企业网关)。对这些 baseURL 做模型发现会失败或产出错误画像。\n\n**方案**:CCX 内置「已知官方 baseURL → 模型清单」映射表,命中时直接返回内置内容,完全跳过上游 models 接口:\n\n```go\n// backend-go/internal/config/builtin_models_manifest.go(生成式,随模型注册表更新)\ntype BuiltinModelsManifest struct {\n BaseURLPattern string `json:\"baseUrlPattern\"` // 精确 host 或前缀匹配,如 \"api.anthropic.com\"\n ServiceType string `json:\"serviceType\"` // messages/responses/…\n PlanHint string `json:\"planHint,omitempty\"` // 订阅类型提示(如 \"claude_subscription\")\n ModelIDs []string `json:\"modelIds\"` // 该入口实际可用的模型清单\n DisableProbe bool `json:\"disableProbe\"` // true 时 Discovery 跳过 GET /v1/models\n}\n```\n\n**两个生效位置**:\n\n```text\n1. Discovery(§4.2 流程第 2 步之前):\n baseURL 命中 manifest 且 DisableProbe=true\n → 跳过 GET /v1/models,直接用 ModelIDs 生成 ModelProfile[]\n → ProfileSource = \"builtin_manifest\",不再因 models 接口失败而误判 misconfigured\n\n2. /v1/models 代理响应(现有 Models API):\n 客户端向 CCX 请求 /v1/models 时,聚合结果中该渠道的贡献\n 直接取内置清单,不向上游发起 models 请求\n (渠道显式配置了 supportedModels 时仍以用户配置优先)\n```\n\n**维护与覆盖顺序**:\n\n```text\n渠道显式 supportedModels(用户手动) > 内置 manifest > 上游 models 接口实探 > 内置候选模型回退\n```\n\nmanifest 与模型注册表同源生成(`generated_model_registry.go` 同级),新模型发布时随注册表一起更新;HealthAnalyzer 对命中 manifest 的 endpoint 不将「models 接口 404/403」计入 misconfigured 信号。\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法确定时 domainStrengthScore 一律取 0.5 中性值,该项对所有候选贡献相同、等效于不存在。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设 + 自定义\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n| `custom` | 用户自定义 | 用户自定义 | 高级用户微调,范围 0.0 ~ 3.0 |\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法判定,中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general(中性),domainStrengthScore 对所有候选取 0.5,该项失效。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选最佳匹配\n // 匹配优先级:\n // a. 同模型族(opus→opus, sonnet→sonnet)—— 最高优先\n // b. 同质量档(premium→premium)\n // c. 上下文窗口最接近(不超也不差太多)\n // d. 探测延迟最低\n best := rankBySimilarity(eligible, requestModel, floor)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**范围**:\n- [ ] 运行时指标驱动画像实时更新\n- [ ] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)\n- [ ] active model filter / context filter 支持自动映射前置判定\n- [ ] 自动恢复探测(limited/dead → healthy)\n- [ ] 晋升/降级机制(连续成功→升级,连续失败→降级)\n- [ ] WebSocket 推送画像变更事件\n- [ ] 前端画像变更历史/时间线\n\n**预估工期**:2-3 周\n\n### Phase 4:高级特性\n\n**范围**:\n- [ ] 多维度标签系统扩展(用户自定义标签)\n- [ ] A/B 测试:同一请求走不同渠道对比\n- [ ] 成本报表:按用户/模型/渠道/key 统计真实有效成本\n- [ ] 订阅中心 provider adapter:可选自动刷新余额/套餐状态\n- [ ] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板\n- [ ] 批量渠道管理(导入/导出/模板)\n- [ ] 渠道推荐:根据使用模式推荐新渠道\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- SQLite schema version 和幂等 migration。\n- 旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。\n- profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。\n- 全局 `killSwitch`、按 task class disable、按 channel disable。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | shadow 样本/准确率/critical misroute 门槛;active 后 SLO regression 自动 rollback;全局 kill switch |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "828107aab226f735e1db5857301811365dff382e": {"t": 1785759388.8858902, "n": 136466, "h": "4656e92c74b3beec", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法确定时 domainStrengthScore 一律取 0.5 中性值,该项对所有候选贡献相同、等效于不存在。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设 + 自定义\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n| `custom` | 用户自定义 | 用户自定义 | 高级用户微调,范围 0.0 ~ 3.0 |\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法判定,中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general(中性),domainStrengthScore 对所有候选取 0.5,该项失效。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选最佳匹配\n // 匹配优先级:\n // a. 同模型族(opus→opus, sonnet→sonnet)—— 最高优先\n // b. 同质量档(premium→premium)\n // c. 上下文窗口最接近(不超也不差太多)\n // d. 探测延迟最低\n best := rankBySimilarity(eligible, requestModel, floor)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**范围**:\n- [ ] 运行时指标驱动画像实时更新\n- [ ] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)\n- [ ] active model filter / context filter 支持自动映射前置判定\n- [ ] 自动恢复探测(limited/dead → healthy)\n- [ ] 晋升/降级机制(连续成功→升级,连续失败→降级)\n- [ ] WebSocket 推送画像变更事件\n- [ ] 前端画像变更历史/时间线\n\n**预估工期**:2-3 周\n\n### Phase 4:高级特性\n\n**范围**:\n- [ ] 多维度标签系统扩展(用户自定义标签)\n- [ ] A/B 测试:同一请求走不同渠道对比\n- [ ] 成本报表:按用户/模型/渠道/key 统计真实有效成本\n- [ ] 订阅中心 provider adapter:可选自动刷新余额/套餐状态\n- [ ] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板\n- [ ] 批量渠道管理(导入/导出/模板)\n- [ ] 渠道推荐:根据使用模式推荐新渠道\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- SQLite schema version 和幂等 migration。\n- 旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。\n- profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。\n- 全局 `killSwitch`、按 task class disable、按 channel disable。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | shadow 样本/准确率/critical misroute 门槛;active 后 SLO regression 自动 rollback;全局 kill switch |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "8fe73b6ad66fbbcde0419201a436d2f8ee6d2d1b": {"t": 1785759391.9876723, "n": 130165, "h": "9344c78c9016e339", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设 + 自定义\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n| `custom` | 用户自定义 | 用户自定义 | 高级用户微调,范围 0.0 ~ 3.0 |\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选最佳匹配\n // 匹配优先级:\n // a. 同模型族(opus→opus, sonnet→sonnet)—— 最高优先\n // b. 同质量档(premium→premium)\n // c. 上下文窗口最接近(不超也不差太多)\n // d. 探测延迟最低\n best := rankBySimilarity(eligible, requestModel, floor)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n },\n \"custom\": {\n \"savingsMultiplier\": 1.0,\n \"providerQualityMultiplier\": 1.0\n }\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**范围**:\n- [ ] 运行时指标驱动画像实时更新\n- [ ] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)\n- [ ] active model filter / context filter 支持自动映射前置判定\n- [ ] 自动恢复探测(limited/dead → healthy)\n- [ ] 晋升/降级机制(连续成功→升级,连续失败→降级)\n- [ ] WebSocket 推送画像变更事件\n- [ ] 前端画像变更历史/时间线\n\n**预估工期**:2-3 周\n\n### Phase 4:高级特性\n\n**范围**:\n- [ ] 多维度标签系统扩展(用户自定义标签)\n- [ ] A/B 测试:同一请求走不同渠道对比\n- [ ] 成本报表:按用户/模型/渠道/key 统计真实有效成本\n- [ ] 订阅中心 provider adapter:可选自动刷新余额/套餐状态\n- [ ] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板\n- [ ] 批量渠道管理(导入/导出/模板)\n- [ ] 渠道推荐:根据使用模式推荐新渠道\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- SQLite schema version 和幂等 migration。\n- 旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。\n- profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。\n- 全局 `killSwitch`、按 task class disable、按 channel disable。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | shadow 样本/准确率/critical misroute 门槛;active 后 SLO regression 自动 rollback;全局 kill switch |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "3c4e53954bd696b6ef7ba30002707bbdbcda7ee4": {"t": 1785759394.5804327, "n": 127956, "h": "9b49b3834028fabe", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选最佳匹配\n // 匹配优先级:\n // a. 同模型族(opus→opus, sonnet→sonnet)—— 最高优先\n // b. 同质量档(premium→premium)\n // c. 上下文窗口最接近(不超也不差太多)\n // d. 探测延迟最低\n best := rankBySimilarity(eligible, requestModel, floor)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**范围**:\n- [ ] 运行时指标驱动画像实时更新\n- [ ] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)\n- [ ] active model filter / context filter 支持自动映射前置判定\n- [ ] 自动恢复探测(limited/dead → healthy)\n- [ ] 晋升/降级机制(连续成功→升级,连续失败→降级)\n- [ ] WebSocket 推送画像变更事件\n- [ ] 前端画像变更历史/时间线\n\n**预估工期**:2-3 周\n\n### Phase 4:高级特性\n\n**范围**:\n- [ ] 多维度标签系统扩展(用户自定义标签)\n- [ ] A/B 测试:同一请求走不同渠道对比\n- [ ] 成本报表:按用户/模型/渠道/key 统计真实有效成本\n- [ ] 订阅中心 provider adapter:可选自动刷新余额/套餐状态\n- [ ] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板\n- [ ] 批量渠道管理(导入/导出/模板)\n- [ ] 渠道推荐:根据使用模式推荐新渠道\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- SQLite schema version 和幂等 migration。\n- 旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。\n- profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。\n- 全局 `killSwitch`、按 task class disable、按 channel disable。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | shadow 样本/准确率/critical misroute 门槛;active 后 SLO regression 自动 rollback;全局 kill switch |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "cbc6bed7d56c20c2b6841b55219f69a5451d4e76": {"t": 1785759397.1184924, "n": 126086, "h": "94b8a54364814634", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,确保稳定性差异始终覆盖派系偏好):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选最佳匹配\n // 匹配优先级:\n // a. 同模型族(opus→opus, sonnet→sonnet)—— 最高优先\n // b. 同质量档(premium→premium)\n // c. 上下文窗口最接近(不超也不差太多)\n // d. 探测延迟最低\n best := rankBySimilarity(eligible, requestModel, floor)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**范围**:\n- [ ] 运行时指标驱动画像实时更新\n- [ ] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)\n- [ ] active model filter / context filter 支持自动映射前置判定\n- [ ] 自动恢复探测(limited/dead → healthy)\n- [ ] 晋升/降级机制(连续成功→升级,连续失败→降级)\n- [ ] WebSocket 推送画像变更事件\n- [ ] 前端画像变更历史/时间线\n\n**预估工期**:2-3 周\n\n### Phase 4:高级特性\n\n**范围**:\n- [ ] 多维度标签系统扩展(用户自定义标签)\n- [ ] A/B 测试:同一请求走不同渠道对比\n- [ ] 成本报表:按用户/模型/渠道/key 统计真实有效成本\n- [ ] 订阅中心 provider adapter:可选自动刷新余额/套餐状态\n- [ ] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板\n- [ ] 批量渠道管理(导入/导出/模板)\n- [ ] 渠道推荐:根据使用模式推荐新渠道\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- SQLite schema version 和幂等 migration。\n- 旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。\n- profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。\n- 全局 `killSwitch`、按 task class disable、按 channel disable。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | shadow 样本/准确率/critical misroute 门槛;active 后 SLO regression 自动 rollback;全局 kill switch |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}, "8cb1f08da90e4858885f246ddadd488390fdba08": {"t": 1786068599.3829, "n": 167479, "h": "245ceceb056f0f83", "c": "# CCX 渠道自动托管 (Channel Autopilot) 设计方案\n\n## 1. 设计目标\n\n> 用户添加渠道只需 baseURL + apiKey,系统自动完成协议发现、模型映射、能力画像、健康诊断、智能调度。\n> 高级用户可覆盖任何自动决策,但默认不需要碰。\n\n### 1.1 核心用户故事\n\n| # | 作为… | 我希望… | 这样我就能… |\n|---|-------|---------|------------|\n| U1 | 有 30-40 个中转站的用户 | 添加渠道时只填 URL + Key | 不用手动配 modelMapping / supportedModels / 兼容开关 |\n| U2 | 想用 Opus 监工 + 白嫖子代理的用户 | 主代理自动走高智商稳定渠道,子代理自动走便宜/快速渠道 | 不用手动给每个渠道调优先级 |\n| U3 | 渠道会死/会限流的用户 | 一眼看到哪些渠道死了、限流了、配置错了 | 快速清理或修复,不用逐个 ping |\n| U4 | 有临时薅羊毛渠道的用户 | 临时池优先消耗,用完自动切常规池 | 不浪费免费额度 |\n| U5 | 同时使用官方 API/token plan、中转站、公益站的用户 | 渠道中心按信任等级展示,但实时质量独立评估 | 知道渠道隐私/治理风险,同时允许低信任上游在状态好时被选中 |\n| U6 | 想临时试新模型/新公益站的用户 | 给模型或渠道设置短期试用意图 | 不改长期策略,也能让真实流量帮忙验证 |\n| U7 | 本地有 Ollama/LM Studio/llama-server 的用户 | 用本地模型承担低风险任务和路由辅助 | 减少重要付费模型调用,同时不让弱模型误判强模型边界 |\n\n### 1.2 设计原则\n\n- **SOLID**:Analyzer、Profiler、ModelResolver、SmartRouter 职责单一,接口隔离\n- **KISS**:先用可解释规则,不做复杂 AI 打分\n- **DRY**:复用现有 MetricsManager、能力测试、模型注册表、CandidateFilter\n- **YAGNI**:Phase 1 只做自动画像 + 健康诊断,Phase 2 做智能调度,Phase 3 做自愈\n- **信任等级 ≠ 服务质量**:官方 API/token plan 是一等信任来源,中转站是二等,公益站是三等;等级表示隐私、治理和可控性,不代表实时服务质量\n- **AI 路由判定只走可信执行面**:会读取用户内容的 AI 判定只能使用一等官方 API/token plan 或本地模型;中转站和公益站的信任等级不足以承担隐私敏感判定\n- **人工意图优先但有边界**:用户可以短期试模型/渠道;系统必须限制 TTL、预算和作用范围,并保留硬约束与自动回退\n- **本地模型只做有边界的辅助**:本地弱模型可以处理低风险任务和生成路由 hint,但不能作为“哪些任务只有 Fable 能做”的通用裁判\n- **Images/Vectors 原生调度优先**:生图和 embedding 作为一等调度类型接入上游原生端点;MVP 不做 chat prompt → image API 的协议转换\n\n---\n\n## 2. 现有基础(可直接复用)\n\n### 2.1 调度器筛选链\n\n`backend-go/internal/scheduler/select.go` 中 `SelectChannelWithOptions` 已有完整筛选链:\n\n```text\nActive+Model过滤 → RoutePrefix过滤 → 上下文过滤 → CandidateFilter回调\n→ Channel Pinning → Manual Override → Promotion优先 → Trace亲和\n→ Priority遍历(含健康检查/熔断/限速/视觉保护) → Soft-skip回退 → Degraded兜底\n```\n\n**关键扩展点**:`SelectionOptions.CandidateFilter` 是注入式回调,autopilot 可在此注入标签/画像过滤逻辑。\n\n### 2.2 指标系统\n\n`backend-go/internal/metrics/` 提供:\n\n| 能力 | 接口 | 说明 |\n|------|------|------|\n| 健康判断 | `IsChannelHealthyMultiURL` | 多 URL 聚合 |\n| 失败率 | `CalculateChannelFailureRateMultiURL` | 滑动窗口 |\n| 聚合指标 | `GetChannelAggregatedMetrics` | 15m 成功率/请求数/缓存率 |\n| 熔断状态 | `GetChannelCircuitStateMultiURL` | Closed/Open/HalfOpen |\n| 失败分类 | `FailureClass` | retryable/overloaded/non_retryable/quota |\n| 请求日志 | `ChannelLog` | 含 AgentRole、模型、延迟、流健康 |\n\n### 2.3 能力测试与模型发现\n\n`backend-go/internal/handlers/channel_discovery.go` 已实现:\n\n- 协议自动探测:对 messages/responses/chat/gemini 四协议并发探测\n- 模型自动发现:拉 `/v1/models`,失败时用内置候选模型回退\n- 模型映射推荐:根据探测结果自动生成 modelMapping\n- 能力探测:工具调用、视觉、thinking passback 测试\n\n`capability_test_runner.go` 提供完整的多模型轮询测试框架。\n\nImages / vectors 需要单独处理:\n- `/v1/images/*` 和 `/v1/embeddings` 进入 Autopilot 调度,但只做原生端点选择、健康画像、成本画像和限速保护。\n- 不复用 chat/messages 的复杂能力测试;MVP 只做最小探测:鉴权、协议路径、模型列表/默认模型、一次小尺寸生图或短文本 embedding(受探测预算限制)。\n- 不做 chat 画图协议转换,也不把“文本对话请求里让模型画图”的 prompt 自动改写为 images API 请求。\n- vectors 的 embedding 维度、模型名和协议字段仍由现有 handler 硬约束过滤;Autopilot 只负责在满足硬约束后排序 endpoint。\n\n### 2.4 模型注册表\n\n`backend-go/internal/config/generated_model_registry.go` + `model_registry.go`:\n\n- `ResolveUpstreamCapability`:四层解析(channel → global → builtin → default)\n- `ResolveAgentModelProfile`:下游代理模型上下文窗口\n- 覆盖 Claude/GPT/Gemini/DeepSeek/Kimi/GLM 等主流模型\n- 每个模型有 ContextWindowTokens、MaxOutputTokens、Capabilities、Pricing\n\n### 2.5 角色识别\n\n`backend-go/internal/utils/headers.go` 的 `ExtractAgentContext`:\n\n- Codex 子代理:`client_metadata.x-openai-subagent` 精确识别\n- Claude Code 子代理:`X-Claude-Code-Agent-Id` header 精确识别\n- 启发式识别:消息数 + 工具调用模式\n- 已用于 trace 亲和隔离(`:subagent` 后缀)\n\n## 3. 数据模型\n\n### 3.1 粒度模型:三层架构\n\n**⚠️ 关键设计决策:画像粒度是 `(baseURL, apiKey)` 对,不是 channel**\n\n原因:\n1. 同一 channel 的不同 baseURL 可能走不同 CDN,延迟/超时/截断行为不同\n2. 同一 channel 的不同 API Key 可能属于不同分组/租户,提供不同的模型列表、协议、价格倍率\n3. Key 可能随时换分组,之前探测的模型列表会失效,需要重新检测\n4. 现有 `MetricsManager` 的 identity 已经是 endpoint 粒度:`GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)`\n\n```text\nChannel(渠道,稳定身份 ChannelUID)\n └── KeyEndpoint(baseURL + apiKey 对) ← 画像的最小单元\n └── ModelProfile(该 endpoint 上的每个实际模型)\n```\n\nChannelProfile 是 KeyEndpoint 画像的聚合视图,用于 UI 展示和调度粗筛。\n\n### 3.1.1 稳定身份与 Metrics Identity\n\n**channel index 不能作为持久身份。** 当前管理 API 和调度器大量使用数组 index 作为 `channelId`,但用户重排、删除、插入渠道后 index 会变化。如果画像表以 index 为主键,历史画像、健康证据、模型快照会串到另一个渠道。\n\n落地时必须先引入稳定身份:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n ChannelUID string `json:\"channelUid,omitempty\"` // 新增:渠道稳定 ID,创建后不因重排/改名/API Key 变更而改变\n}\n```\n\n规则:\n- `ChannelUID` 在新增渠道时生成;读取旧配置时由 ConfigManager 补齐并持久化一次。\n- `ChannelID` 继续表示当前数组 index,仅用于兼容旧 API 和 UI 展示,不参与新画像表主键。\n- `ChannelUID` 不从 name/baseURL/apiKey 推导,避免用户改名或换 key 后画像断裂。\n- 删除渠道后,以 `channel_uid + channel_kind` 清理画像;重排渠道不触发画像迁移。\n\nMetrics identity 也必须与现有实现保持一致:\n\n```text\nidentityBaseURL = utils.MetricsIdentityBaseURL(baseURL, serviceType)\nmetricsKey = metrics.GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\nlookupKeys = metrics.GenerateMetricsLookupKeys(baseURL, apiKey, serviceType)\n```\n\n注意:\n- `serviceType` 影响 `identityBaseURL` 的默认版本前缀,必须写入画像记录。\n- `lookupKeys` 只用于兼容旧历史数据,ProfileStore 主键使用当前规范 `metricsKey`。\n- 画像表不保存明文 API Key,只保存 `keyMask` 和 `metricsKey`;如需按 key 展示名称,读取 `APIKeyConfig.Name`。\n\n### 3.2 KeyEndpoint 画像 (KeyEndpointProfile)\n\n**画像的最小单元**,对应一个具体的 `baseURL + apiKey` 组合:\n\n```go\n// backend-go/internal/autopilot/key_endpoint_profile.go\n\ntype KeyEndpointProfile struct {\n // ── 身份 ──\n ChannelUID string `json:\"channelUid\"` // 稳定渠道 ID,用于持久化主键\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"` // official_api | official_token_plan | relay | community | unknown\n OriginTier string `json:\"originTier\"` // first | second | third | local | unknown;信任/隐私等级,不代表实时质量\n ServiceType string `json:\"serviceType\"` // metrics identity 依赖 serviceType\n BaseURL string `json:\"baseUrl\"` // 原始配置 URL,用于展示\n IdentityBaseURL string `json:\"identityBaseUrl\"` // MetricsIdentityBaseURL(baseURL, serviceType)\n KeyMask string `json:\"keyMask\"` // 掩码后的 key,如 sk-***abc\n MetricsKey string `json:\"metricsKey\"` // GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 自动推导维度 ──\n HealthState HealthState `json:\"healthState\"`\n HealthConfidence float64 `json:\"healthConfidence\"`\n QualityTier QualityTier `json:\"qualityTier\"`\n StabilityTier StabilityTier `json:\"stabilityTier\"`\n SpeedTier SpeedTier `json:\"speedTier\"`\n CostTier CostTier `json:\"costTier\"`\n CostProfile CostProfile `json:\"costProfile,omitempty\"`\n\n // ── 能力标签(该 endpoint 特有)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 该 endpoint 的可用模型列表 ──\n AvailableModels []string `json:\"availableModels\"` // 探测到的实际模型列表\n ModelMapping map[string]string `json:\"modelMapping\"` // 该 endpoint 的模型映射\n\n // ── 运行时指标(来自 MetricsManager)──\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n ConsecutiveFail int `json:\"consecutiveFail\"`\n LastSuccessAt *time.Time `json:\"lastSuccessAt,omitempty\"`\n LastFailureAt *time.Time `json:\"lastFailureAt,omitempty\"`\n\n // ── 自动限速画像(仅在未显式配置时用于运行态 limiter)──\n DiscoveredRPM int `json:\"discoveredRpm,omitempty\"`\n DiscoveredMaxConcurrent int `json:\"discoveredMaxConcurrent,omitempty\"`\n RateLimitSource string `json:\"rateLimitSource,omitempty\"` // manual | header | passive_aimd | unknown\n RateLimitConfidence float64 `json:\"rateLimitConfidence,omitempty\"`\n\n // ── 分组感知 ──\n DetectedGroup string `json:\"detectedGroup,omitempty\"` // 检测到的 key 分组标识\n GroupChangedAt *time.Time `json:\"groupChangedAt,omitempty\"` // 分组变更时间\n ModelListHash string `json:\"modelListHash,omitempty\"` // 模型列表哈希,用于检测变更\n\n // ── 诊断 ──\n HealthEvidence []string `json:\"healthEvidence\"`\n SuggestedAction SuggestedAction `json:\"suggestedAction\"`\n\n // ── 元数据 ──\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// CostProfile 描述该 endpoint 的实际成本倍率。\n// 目标是把模型注册表里的公开 USD 价格换算成用户真实付费成本。\ntype CostProfile struct {\n // 分组倍率:不同上游账号组/模型组常有不同计费倍率。\n // key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:充值赠送或折扣换算。1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 最终成本倍率,按请求模型解析后计算:\n // effective = groupMultiplier / rechargeMultiplier\n EffectiveCostMultiplier float64 `json:\"effectiveCostMultiplier,omitempty\"`\n\n // 基于模型注册表 Pricing × EffectiveCostMultiplier 得到的估算价格。\n EffectiveInputCostPerMTok float64 `json:\"effectiveInputCostPerMTok,omitempty\"`\n EffectiveOutputCostPerMTok float64 `json:\"effectiveOutputCostPerMTok,omitempty\"`\n EffectiveEmbeddingCostPerMTok float64 `json:\"effectiveEmbeddingCostPerMTok,omitempty\"`\n EffectiveImageUnitCost float64 `json:\"effectiveImageUnitCost,omitempty\"`\n\n Source string `json:\"source\"` // manual | default | inferred\n Confidence float64 `json:\"confidence\"` // 手动配置为 1.0\n}\n```\n\n成本规则:\n- 文本类请求使用 input/output token 价格。\n- vectors 使用 embedding token 价格;如果模型注册表缺少 embedding 价格,只做 shadow 展示,不参与强排序。\n- images 使用按张/尺寸/质量/operation 的 unit price;不要用 chat token 价格估算生图成本。\n- `EffectiveImageUnitCost` 可以是按当前请求参数计算后的请求级值;ProfileStore 只缓存默认尺寸/质量的代表值。\n\n```go\n// SubscriptionProfile 描述渠道背后的套餐/余额/价格来源。\n// 它由订阅中心维护,被渠道中心和智能路由读取。\ntype SubscriptionProfile struct {\n SubscriptionUID string `json:\"subscriptionUid\"`\n DisplayName string `json:\"displayName\"`\n Provider string `json:\"provider\"` // openai | anthropic | google | relay_x | community_x | custom\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"`\n\n BillingMode string `json:\"billingMode\"` // official_api | token_plan | prepaid_credit | shared_free | unknown\n Currency string `json:\"currency,omitempty\"`\n Balance float64 `json:\"balance,omitempty\"`\n BalanceUpdatedAt *time.Time `json:\"balanceUpdatedAt,omitempty\"`\n\n // 套餐默认成本倍率;channel/key 可继续覆盖。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n LinkedChannelUIDs []string `json:\"linkedChannelUids,omitempty\"`\n Source string `json:\"source\"` // manual | imported | inferred\n Confidence float64 `json:\"confidence\"`\n}\n```\n\n```go\n// ChannelOriginType 描述渠道来源。它属于渠道中心的信任/隐私治理维度,不属于质量画像。\ntype ChannelOriginType string\n\nconst (\n OriginOfficialAPI ChannelOriginType = \"official_api\" // 官方 API key\n OriginOfficialTokenPlan ChannelOriginType = \"official_token_plan\" // 官方 token/subscription plan\n OriginRelay ChannelOriginType = \"relay\" // 付费/商业中转站\n OriginCommunity ChannelOriginType = \"community\" // 公益站/白嫖站/临时共享站\n OriginLocalRuntime ChannelOriginType = \"local_runtime\" // 本地 Ollama/LM Studio/llama-server\n OriginUnknown ChannelOriginType = \"unknown\"\n)\n\ntype ChannelOriginTier string\n\nconst (\n OriginTierFirst ChannelOriginTier = \"first\" // 官方 API / 官方 token plan,可用于隐私敏感 advisor\n OriginTierSecond ChannelOriginTier = \"second\" // 中转站,不用于隐私敏感 advisor\n OriginTierThird ChannelOriginTier = \"third\" // 公益站,不用于隐私敏感 advisor\n OriginTierLocal ChannelOriginTier = \"local\" // 用户自托管本地运行时,可用于隐私敏感 advisor\n OriginTierUnknown ChannelOriginTier = \"unknown\"\n)\n```\n\n### 3.2.1 信任与隐私等级 (OriginTier)\n\n`OriginTier` 是渠道中心的一等维度,用来表达隐私信任、来源可控性、账单可解释性和长期可维护性:\n\n| 来源 | OriginType | OriginTier | 语义 |\n|------|------------|------------|------|\n| 官方 API | `official_api` | `first` | 直接使用模型厂商 API key,账单和限额最清楚 |\n| 官方 token plan | `official_token_plan` | `first` | 官方订阅/token 计划,虽然计费形态不同,但信任/隐私等级等同官方 API |\n| 中转站 | `relay` | `second` | 第三方商业代理或聚合站,价格/模型/限额可能有二次包装 |\n| 公益站 | `community` | `third` | 公益、共享、临时或白嫖站,波动和不可控性更高 |\n| 本地运行时 | `local_runtime` | `local` | 用户自托管 Ollama/LM Studio/llama-server,治理可控但质量由本地模型决定 |\n| 未知 | `unknown` | `unknown` | 未标注来源,只做观察,不自动假设等级 |\n\n约束:\n- `OriginTier` **不得参与 QualityTier 推导**,也不得把 `first` 自动视为高质量、把 `third` 自动视为低质量。\n- `OriginTier` 可以影响渠道中心默认分组、风险提示、成本解释和同分调度 tie-breaker。\n- 当三等来源在短时间内拥有更高成功率、更低延迟或更稳定流式表现时,SmartRouter 必须允许它在满足硬约束后胜出。\n- `OriginTier=local` 不自动等同高质量或低质量;本地模型质量必须由 LocalModelRuntimeProfile 和运行画像决定。\n- 凡是需要读取用户 prompt、系统提示、上下文摘要或敏感 metadata 的 AI 判定,只允许 `OriginTier=first` 或 `OriginTier=local`。\n- `OriginTier=second|third|unknown` 可以作为候选执行上游,但不能作为 routing advisor / classifier / privacy-sensitive evaluator。\n- 如果一个 channel 混入不同来源的 baseURL/key,UI 标记为「来源混杂」,并建议拆成多个 channel;MVP 不做 endpoint 级来源 override。\n\n### 3.2.2 订阅与渠道链接\n\n`SubscriptionProfile` 是订阅中心的最小业务对象,`UpstreamConfig.SubscriptionUID` 是渠道到订阅/套餐的链接:\n\n```text\nSubscriptionProfile(官方 API / token plan / 中转套餐 / 公益来源)\n └── Channel(一个订阅可挂多个协议渠道)\n └── KeyEndpoint(baseURL + apiKey)\n```\n\n规则:\n- 官方 API key 和官方 token plan 都归入 `OriginTier=first`,但 `BillingMode` 不同,价格计算要走各自套餐逻辑。\n- 中转站归入 `OriginTier=second`,其 `GroupMultipliers`、`RechargeMultiplier` 优先从订阅套餐继承,再被 channel/key 覆盖。\n- 公益站归入 `OriginTier=third`,允许 `BillingMode=shared_free`,但仍要记录 RPM、稳定性和质量趋势。\n- 订阅中心负责维护余额、套餐倍率、续费周期和备注;渠道中心只展示链接结果和运行状态,不重复维护同一套价格字段。\n- 未链接订阅的历史渠道允许继续运行,UI 显示为「未归档来源」,不会阻塞调度。\n\n### 3.2.3 同订阅多 Key:能力共享与配额负载均衡\n\n**问题**:官方订阅 plan(Claude Max、Codex plan 等)允许用户持有多个 key,或购买多份相同套餐。若每个 endpoint 独立探测与配置,探测预算被浪费、配置重复;请求量大时还需要在同 plan 的多个 key 之间按官方 RPM 限速自动负载均衡,而不是打满第一个 key 才溢出到下一个。\n\n**能力检测去重(同一套餐只需一套能力检测和配置)**:\n\n```text\nSubscriptionUID 相同的 endpoint 共享一份「订阅级能力画像」:\n 模型列表 / vision / tool / reasoning 支持 / 协议兼容开关 → 只探测一次,\n 存在 SubscriptionProfile.SharedCapability,endpoint 画像以引用方式继承。\n\n仍然 per-endpoint 维护的:健康状态、延迟、熔断、用量窗口\n (key 可能被单独封禁/限速,能力相同不代表运行态相同)\n\n继承失效条件:某 key 实测模型列表哈希与订阅级不一致(GroupChangeDetector 信号)\n → 该 key 脱离继承、独立探测,并在 UI 标记「与套餐能力不一致」警告。\n```\n\n**配额负载均衡(Phase 1 shadow 展示,Phase 2 生效)**:\n\n```text\n每 key 维护配额余量视图:\n remainingRPM = officialRPM − 当前窗口已用\n officialRPM 来源优先级:内置 manifest > 用户配置 > RateLimitDiscoverer 建议\n\n同订阅多 key 时按「剩余余量加权」分配请求(余量大者优先、\n窗口最早重置者兜底),替代顺序打满:\n 仅当渠道 autoManaged 且同一 SubscriptionUID 下 key 数 > 1 时启用;\n 否则保持现有 sequential/random key 顺序策略不变。\n\n某 key 的窗口配额耗尽 → 临时降权并切到同订阅其他 key,\n不熔断整个渠道(这是配额耗尽,不是故障)。\n```\n\n### 3.2.4 订阅用量窗口 (Usage Windows)\n\n部分官方 plan 提供 5h / 日 / 周 / 月等窗口的用量查询与限制(如 Claude 订阅的 5h 滚动窗口 + 周配额)。在 key 的位置展示这些窗口,帮助用户掌握使用情况:\n\n```go\ntype UsageWindow struct {\n Window string `json:\"window\"` // \"5h\" | \"day\" | \"week\" | \"month\"\n Used float64 `json:\"used\"` // 已用量\n Limit float64 `json:\"limit\"` // 上限,0 = 未知\n Unit string `json:\"unit\"` // requests | tokens | credits | percent\n ResetsAt time.Time `json:\"resetsAt\"` // 窗口重置时间\n Source string `json:\"source\"` // official_api | response_header | local_metering\n FetchedAt time.Time `json:\"fetchedAt\"`\n}\n// KeyEndpointProfile 与 SubscriptionProfile 均增加 UsageWindows []UsageWindow\n```\n\n数据来源优先级:\n\n```text\n1. 官方用量 API:有 provider adapter 的 plan 主动拉取(§Phase 4 订阅 adapter 的可提前子集)\n2. 响应头:anthropic-ratelimit-* / x-ratelimit-* 等(复用 RateLimitDiscoverer 的信号 tap)\n3. 本地计量兜底:CCX 按 key 累计请求数/token(误差来自窗口起点对齐,标注 Source=local_metering)\n```\n\nUI 要求:key 行内显示主窗口(5h)用量进度条,悬浮展开全部窗口;用量 >80% 黄色预警、超限红色并显示预计重置时间。调度联动(Phase 2):窗口余量进入配额负载均衡权重,窗口耗尽的 key 临时降权而非熔断。\n\n### 3.3 Channel 画像 (ChannelProfile) — 聚合视图\n\nChannelProfile 不再存储原始数据,而是从 KeyEndpoint 画像聚合而来:\n\n```go\n// backend-go/internal/autopilot/channel_profile.go\n\ntype ChannelProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n OriginType string `json:\"originType\"`\n OriginTier string `json:\"originTier\"` // 信任/隐私等级,不参与质量聚合\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 聚合维度(取所有 endpoint 的\"最差可用\"或\"最佳代表\")──\n HealthState HealthState `json:\"healthState\"` // 取最差:任一 endpoint dead → degraded\n QualityTier QualityTier `json:\"qualityTier\"` // 取最佳 endpoint 的质量\n StabilityTier StabilityTier `json:\"stabilityTier\"` // 取中位数\n SpeedTier SpeedTier `json:\"speedTier\"` // 取中位数\n CostTier CostTier `json:\"costTier\"` // 取最佳 endpoint 的成本\n\n // ── 能力标签(取并集,但标注不一致)──\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n SupportsLongCtx bool `json:\"supportsLongCtx\"`\n\n // ── 聚合指标 ──\n TotalEndpoints int `json:\"totalEndpoints\"`\n HealthyEndpoints int `json:\"healthyEndpoints\"`\n TotalModels int `json:\"totalModels\"` // 去重后的模型总数\n SuccessRate15m float64 `json:\"successRate15m\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n\n // ── 能力不一致警告 ──\n EndpointInconsistencies []EndpointInconsistency `json:\"endpointInconsistencies,omitempty\"`\n\n Source string `json:\"source\"`\n Confidence float64 `json:\"confidence\"`\n}\n\n// EndpointInconsistency 记录同一 channel 内不同 endpoint 的能力差异\ntype EndpointInconsistency struct {\n Dimension string `json:\"dimension\"` // \"quality\" | \"vision\" | \"models\" | \"latency\"\n Detail string `json:\"detail\"` // 例如 \"endpoint-1: premium, endpoint-2: normal\"\n Severity string `json:\"severity\"` // \"info\" | \"warning\"\n}\n```\n\n**聚合策略**:\n\n```text\n维度 │ 策略 │ 原因\n────────────┼───────────────────┼──────────────────────────────\nHealthState │ 取最差 │ 任一 endpoint 死了,整个渠道至少 degraded\nQualityTier │ 取最佳 │ 调度时选最佳 endpoint 的质量档\nStability │ 取中位数 │ 避免单个坏 endpoint 拉低整个渠道\nSpeed │ 取中位数 │ 同上\nCost │ 取最佳 │ 便宜的 endpoint 存在就有价值\nVision等 │ 取并集 │ 只要有一个 endpoint 支持就算支持\nOriginTier │ 渠道级字段 │ 信任等级是隐私/治理属性,不从 endpoint 运行质量聚合\n```\n\n### 3.4 模型画像 (ModelProfile)\n\n每个 `(KeyEndpoint + 模型)` 组合的画像:\n\n```go\n// backend-go/internal/autopilot/model_profile.go\n\ntype ModelProfile struct {\n // ── 锚定到 KeyEndpoint ──\n ChannelUID string `json:\"channelUid\"`\n ChannelID int `json:\"channelId\"` // 当前配置数组 index,仅用于展示/兼容\n ChannelKind string `json:\"channelKind\"`\n ServiceType string `json:\"serviceType\"`\n MetricsKey string `json:\"metricsKey\"` // 精确到 identityBaseURL+key+serviceType\n ModelID string `json:\"modelId\"` // 该 endpoint 内的实际模型名\n UpdatedAt time.Time `json:\"updatedAt\"`\n\n // ── 能力 ──\n ModelFamily ModelFamily `json:\"modelFamily\"` // 派系:claude/openai/gemini/deepseek/…,从注册表推导\n QualityTier QualityTier `json:\"qualityTier\"` // 基于模型族的质量档(premium/high/normal/low)\n SpeedTier SpeedTier `json:\"speedTier\"`\n ContextTokens int `json:\"contextTokens\"`\n SupportsVision bool `json:\"supportsVision\"`\n SupportsToolCalls bool `json:\"supportsToolCalls\"`\n SupportsReasoning bool `json:\"supportsReasoning\"`\n\n // ── 上游供应商质量(同模型在不同上游的质量差异)──\n // 解决问题:同一 claude-opus-4-8,官方 Anthropic vs AWS Bedrock vs kiro 中转的质量可能差异很大\n // 与 QualityTier 的区别:QualityTier 基于模型族(opus=premium),ProviderQuality 区分同族内的上游差异\n ProviderQualityScore float64 `json:\"providerQualityScore,omitempty\"` // 0.0-1.0,该endpoint对该模型的实际实现质量\n ProviderQualitySource string `json:\"providerQualitySource,omitempty\"` // probe | user_feedback | inferred | default\n ProviderQualityConfidence float64 `json:\"providerQualityConfidence,omitempty\"` // 置信度\n\n // ── 任务域优势(不同模型的强项任务不同)──\n // 解决问题:opus/fable/gemini/glm 的审美明显好于 gpt,但 gpt 的代码审核堪比 fable\n // 缺省时回退到 ModelFamily 级种子矩阵(§5.7),0.5 = 中性\n TaskDomainStrengths map[TaskDomain]float64 `json:\"taskDomainStrengths,omitempty\"`\n\n // ── 思考等级(同模型不同思考档位的智商差异,§5.8)──\n SupportsEffortControl bool `json:\"supportsEffortControl,omitempty\"` // 上游是否可控思考等级\n SupportedEffortLevels []EffortLevel `json:\"supportedEffortLevels,omitempty\"` // 可用档位(按派系映射)\n\n // ── 探测结果 ──\n ProbeSuccess bool `json:\"probeSuccess\"`\n LastProbeAt time.Time `json:\"lastProbeAt\"`\n ProbeLatencyMs int64 `json:\"probeLatencyMs\"`\n ProbeConfidence float64 `json:\"probeConfidence\"`\n\n // ── 来源 ──\n Source string `json:\"source\"` // builtin_registry | auto_probe | capability_test | manual\n}\n```\n\n### 3.5 请求画像 (RequestProfile)\n\n每次请求在进入调度器前生成,不持久化:\n\n```go\n// backend-go/internal/autopilot/request_profile.go\n\ntype RequestProfile struct {\n // ── 来自请求 ──\n Model string // 请求的目标模型\n ChannelKind string // messages | chat | responses | gemini | images | vectors\n Operation string // completion | image_generation | image_edit | image_variation | embedding\n AgentRole string // \"main\" | \"subagent\"\n AgentType string // \"codex_subagent\" | \"claude_code_subagent\"\n HasImage bool // 是否包含图片\n EstTokens int // 估算输入 token 数\n\n // ── 来自模型注册表 ──\n QualityNeed QualityTier // 该模型对应的质量需求\n ContextNeed int // 最小上下文窗口\n VisionNeed bool // 是否需要识图\n ImageGenNeed bool // 是否需要原生生图端点\n EmbeddingNeed bool // 是否需要原生 embedding 端点\n ToolUseNeed bool // 是否需要工具调用\n ReasoningNeed bool // 是否需要推理\n EmbeddingDimension int // vectors handler 的硬约束;未知时为 0\n\n // ── 任务分类 ──\n TaskClass TaskClass // supervisor | worker | lightweight | vision | long_context | image_generation | embedding\n}\n\ntype TaskClass string\nconst (\n TaskClassSupervisor TaskClass = \"supervisor\" // 主代理/监工\n TaskClassWorker TaskClass = \"worker\" // 子代理/干活\n TaskClassLightweight TaskClass = \"lightweight\" // 轻任务(摘要/标题)\n TaskClassVision TaskClass = \"vision\" // 识图任务\n TaskClassLongContext TaskClass = \"long_context\" // 长上下文任务\n TaskClassImageGen TaskClass = \"image_generation\" // 原生生图任务\n TaskClassEmbedding TaskClass = \"embedding\" // 原生向量/embedding 任务\n)\n```\n\n### 3.6 本地模型运行时 (LocalModelRuntimeProfile)\n\n本地模型既可以作为普通候选上游,也可以作为路由辅助模型。支持目标:\n- Ollama:本地模型服务,可通过 native API 或 OpenAI-compatible API 接入。\n- LM Studio:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- llama-server / llama.cpp server:通常以 OpenAI-compatible `/v1` 暴露本地模型。\n- 其他本地 OpenAI-compatible 服务:按 `runtimeType=openai_compatible` 处理。\n\n```go\n// LocalModelRuntimeProfile 描述一个本地运行时中的一个模型。\ntype LocalModelRuntimeProfile struct {\n RuntimeUID string `json:\"runtimeUid\"`\n Name string `json:\"name,omitempty\"`\n\n // ollama | lmstudio | llama_server | openai_compatible\n RuntimeType string `json:\"runtimeType\"`\n BaseURL string `json:\"baseUrl\"` // 例如 http://localhost:11434 或 http://localhost:1234/v1\n Model string `json:\"model\"`\n\n // candidate_model: 可直接承接请求;routing_advisor: 只产出路由 hint\n Roles []string `json:\"roles\"`\n\n OriginType string `json:\"originType\"` // local_runtime\n OriginTier string `json:\"originTier\"` // local\n\n ContextTokens int `json:\"contextTokens,omitempty\"`\n SupportsTools bool `json:\"supportsTools,omitempty\"`\n SupportsVision bool `json:\"supportsVision,omitempty\"`\n SupportsReasoning bool `json:\"supportsReasoning,omitempty\"`\n\n // 本地资源画像\n P95LatencyMs int64 `json:\"p95LatencyMs,omitempty\"`\n TokensPerSecond float64 `json:\"tokensPerSecond,omitempty\"`\n MaxPromptTokensForAdvisor int `json:\"maxPromptTokensForAdvisor,omitempty\"`\n TimeoutMs int `json:\"timeoutMs,omitempty\"`\n\n // 路由辅助可靠度,只能通过 shadow 评估提升\n AdvisorConfidence float64 `json:\"advisorConfidence,omitempty\"`\n AdvisorShadowAccuracy float64 `json:\"advisorShadowAccuracy,omitempty\"`\n LastProbeAt *time.Time `json:\"lastProbeAt,omitempty\"`\n Status string `json:\"status\"` // healthy | slow | unavailable | unknown\n}\n```\n\n本地运行时规则:\n- 作为 `candidate_model` 时,它和普通 channel 一样进入 ChannelProfile/ModelProfile,但默认只承接 lightweight/worker 的低风险任务。\n- 作为 `routing_advisor` 时,它只输出结构化 `RoutingHint`,不直接决定最终路由。\n- 本地模型默认有效成本接近 0,但不能因此覆盖质量下界;它的真实成本可展示为本机资源成本或用户自定义电费/硬件摊销。\n- 本地 runtime 作为 advisor 时必须设置超时;超时或不可用时直接 fail-open,回到默认 SmartRouter。\n- 不自动把远端强模型任务切分给本地模型处理;只有用户配置的任务模板允许本地预处理或摘要。\n\n### 3.7 人工路由意图 (ManualRoutingIntent)\n\n用户需要适度干预系统选择,而不是长期关闭 Autopilot。典型场景:\n- 新模型试用:用户暂时想测试 `fable-5`,即使模型注册表和画像还不完整。\n- 新公益站试用:用户希望某个公益站先接一部分 worker/lightweight 流量,用真实请求快速收集画像。\n- 单会话排障:用户只想让当前会话固定走某个 channel/endpoint,验证兼容性。\n\n```go\n// ManualRoutingIntent 是用户显式表达的短期路由意图。\n// 它比 SmartRouter 默认排序优先,但不能绕过协议、鉴权、上下文、vision/tool 等硬约束。\ntype ManualRoutingIntent struct {\n IntentUID string `json:\"intentUid\"`\n Name string `json:\"name,omitempty\"`\n\n // model_trial | channel_trial | endpoint_trial | session_pin\n IntentType string `json:\"intentType\"`\n\n ChannelKind string `json:\"channelKind\"` // messages/chat/responses/gemini/images/vectors\n ChannelUID string `json:\"channelUid,omitempty\"` // 可选:指定渠道\n MetricsKey string `json:\"metricsKey,omitempty\"` // 可选:精确到 baseURL+key endpoint\n Model string `json:\"model,omitempty\"` // 请求模型,例如 fable-5\n MappedModel string `json:\"mappedModel,omitempty\"` // 可选:上游实际模型\n\n // 作用范围\n AgentRoles []string `json:\"agentRoles,omitempty\"` // main/subagent;为空表示全部\n TaskClasses []TaskClass `json:\"taskClasses,omitempty\"`\n SessionID string `json:\"sessionId,omitempty\"` // 可选:只影响当前会话\n TrafficPercent int `json:\"trafficPercent,omitempty\"` // 1-100;默认 100\n\n // 安全边界\n ExpiresAt time.Time `json:\"expiresAt\"`\n MaxRequests int `json:\"maxRequests,omitempty\"`\n MaxEstimatedCost float64 `json:\"maxEstimatedCost,omitempty\"`\n FallbackOnFailure bool `json:\"fallbackOnFailure\"` // true: 失败后回到 Autopilot 默认计划\n RequireHardConstraints bool `json:\"requireHardConstraints\"` // 默认 true\n\n // 观测\n CreatedBy string `json:\"createdBy,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n Reason string `json:\"reason,omitempty\"`\n Status string `json:\"status\"` // active | expired | exhausted | disabled\n}\n```\n\n规则:\n- `ManualRoutingIntent` 是**临时意图**,默认 TTL 不超过 24 小时;超过需要用户重新确认。\n- `model_trial` 允许未知模型进入指定 channel/endpoint 的探测流量,但结果只写入 trial 画像,不自动写 `modelMapping`。\n- `channel_trial` / `endpoint_trial` 只改变候选优先级,不把公益站永久提升为高优先级。\n- `session_pin` 只影响指定会话,不改变全局调度。\n- 即使用户显式试用,也不能绕过鉴权、协议兼容、上下文窗口、vision/tool/reasoning 等硬约束;如用户选择“仍要尝试未知能力”,UI 必须显示风险并限制在 session scope。\n- trial 的成功率、延迟、断流、成本和错误会进入画像,但标记 `Source=manual_trial`,避免污染长期自动推导。\n\n### 3.8 存储方案\n\n| 数据 | 存储 | TTL |\n|------|------|-----|\n| KeyEndpointProfile | SQLite `key_endpoint_profiles` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ChannelProfile | 不持久化,运行时从 KeyEndpoint 聚合 | 内存计算 |\n| ModelProfile | SQLite `model_profiles` 表 + 内存缓存 | 持久化,运行时 10min 刷新 |\n| RequestProfile | 内存 | 请求级,不持久化 |\n| LocalModelRuntimeProfile | SQLite `local_model_runtimes` 表 + 内存缓存 | 持久化,运行时 5min 刷新 |\n| ManualRoutingIntent | SQLite `manual_routing_intents` 表 + 内存缓存 | 到期后保留结果 7 天 |\n| TimeBucketMetrics | SQLite `time_bucket_metrics` 表 + 内存聚合桶 | 7 天滚动清理 |\n| 健康证据 | SQLite `health_evidence` 表 | 7 天滚动清理 |\n| 模型列表快照 | SQLite `model_list_snapshots` 表 | 30 天滚动清理,用于检测分组变更 |\n| 画像变更日志 | SQLite `profile_changelog` 表 | 30 天滚动清理 |\n\n```sql\n-- 画像最小单元:baseURL + apiKey 对\nCREATE TABLE key_endpoint_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- GenerateMetricsIdentityKey(baseURL, apiKey, serviceType)\n identity_base_url TEXT NOT NULL, -- MetricsIdentityBaseURL(baseURL, serviceType)\n base_url TEXT NOT NULL, -- 原始配置 URL\n key_mask TEXT NOT NULL, -- 掩码后的 key\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key)\n);\nCREATE INDEX idx_key_endpoint_profiles_kind_index ON key_endpoint_profiles(channel_kind, channel_id);\n\n-- 模型画像锚定到 endpoint\nCREATE TABLE model_profiles (\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n service_type TEXT NOT NULL,\n metrics_key TEXT NOT NULL, -- 精确到 endpoint\n model_id TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, model_id)\n);\nCREATE INDEX idx_model_profiles_kind_index ON model_profiles(channel_kind, channel_id);\n\nCREATE TABLE local_model_runtimes (\n runtime_uid TEXT PRIMARY KEY,\n runtime_type TEXT NOT NULL,\n base_url TEXT NOT NULL,\n model TEXT NOT NULL,\n profile_json TEXT NOT NULL,\n status TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_local_model_runtimes_type ON local_model_runtimes(runtime_type, status);\n\n-- 短期人工试用意图\nCREATE TABLE manual_routing_intents (\n intent_uid TEXT PRIMARY KEY,\n intent_type TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n intent_json TEXT NOT NULL,\n status TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX idx_manual_routing_intents_active\n ON manual_routing_intents(channel_kind, status, expires_at);\n\n-- 模型列表快照,用于检测 key 换分组\nCREATE TABLE model_list_snapshots (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL,\n model_list TEXT NOT NULL, -- JSON array of model IDs\n list_hash TEXT NOT NULL, -- SHA256 of sorted model list\n detected_at TEXT NOT NULL\n);\nCREATE INDEX idx_snapshots_channel ON model_list_snapshots(channel_uid, channel_kind, detected_at);\n\nCREATE TABLE health_evidence (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_uid TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n channel_kind TEXT NOT NULL,\n metrics_key TEXT NOT NULL DEFAULT '', -- 精确到 endpoint\n kind TEXT NOT NULL,\n evidence TEXT NOT NULL,\n severity TEXT NOT NULL,\n created_at TEXT NOT NULL\n);\nCREATE INDEX idx_health_evidence_channel ON health_evidence(channel_uid, channel_kind, created_at);\nCREATE INDEX idx_health_evidence_endpoint ON health_evidence(channel_uid, channel_kind, metrics_key, created_at);\n```\n\n配置同步规则:\n- 服务启动和配置热重载时,按 `channel_uid + channel_kind` 对齐当前 index,刷新画像中的 `channel_id` 展示快照。\n- 配置中不存在的 `channel_uid` 标记为 `orphaned`,延迟清理;立即删除可能影响正在展示的历史诊断。\n- API 返回时同时带 `channelUid` 和 `channelId`;所有写操作使用 `channelUid`,只读兼容接口可继续接收当前 index。\n\n### 3.9 Key 分组变更检测\n\n当 key 换了分组,之前的模型发现结果会失效。检测机制:\n\n```go\n// backend-go/internal/autopilot/group_change_detector.go\n\ntype GroupChangeDetector struct {\n profileStore *ProfileStore\n}\n\n// CheckGroupChange 对比本次探测的模型列表与上次快照\n// 返回 (changed bool, oldHash string, newHash string, addedModels []string, removedModels []string)\nfunc (d *GroupChangeDetector) CheckGroupChange(\n channelUID string,\n channelKind string,\n metricsKey string,\n currentModels []string,\n) (bool, GroupChangeResult) {\n // 1. 取上次快照\n lastSnapshot := d.profileStore.GetLatestModelListSnapshot(channelUID, channelKind, metricsKey)\n if lastSnapshot == nil {\n // 首次,记录快照\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n return false, GroupChangeResult{}\n }\n\n // 2. 计算当前模型列表哈希\n currentHash := hashModelList(currentModels)\n\n // 3. 对比\n if currentHash == lastSnapshot.ListHash {\n return false, GroupChangeResult{} // 无变化\n }\n\n // 4. 分组变更!计算差异\n added, removed := diffModelLists(lastSnapshot.ModelList, currentModels)\n\n // 5. 记录变更\n now := time.Now()\n d.profileStore.SaveModelListSnapshot(channelUID, channelKind, metricsKey, currentModels)\n d.profileStore.UpdateGroupChangedAt(channelUID, channelKind, metricsKey, now)\n\n return true, GroupChangeResult{\n OldHash: lastSnapshot.ListHash,\n NewHash: currentHash,\n AddedModels: added,\n RemovedModels: removed,\n ChangedAt: now,\n }\n}\n```\n\n**触发时机**(与 L1/L2/L3 信号结合):\n\n```text\n场景 │ 触发方式 │ 动作\n──────────────────────────────┼───────────────────────┼──────────────────────────────\nL1 被动:请求返回 model_not_found │ 真实请求失败 │ 触发该 endpoint 的 L2 重探测\nL2 探测:定期轻量探测 │ 每 2h 一次 │ 拉 /models 对比快照\n新 key 添加 │ 配置变更事件 │ 立即触发 L2 探测\n用户手动「重新探测」 │ UI 按钮 │ 立即触发 L3 深度探测\n```\n\n**分组变更后的动作**:\n\n```text\n1. 标记该 endpoint 的 ModelProfile 为 stale\n2. 重新触发 L2 模型发现(拉 /models)\n3. 重新生成 ModelProfile\n4. 如果模型列表变化导致能力标签变化 → 更新 KeyEndpointProfile → 重新聚合 ChannelProfile\n5. 通知前端:endpoint 模型列表已变更\n```\n\n### 3.10 时序感知 — 快照到持续评估\n\n**⚠️ 画像不是\"探测一次定终身\"。上游同一分组在忙时和闲时的质量差异可能很大。**\n\n一个 endpoint 在凌晨 3 点测试通过,不代表早高峰 9 点仍然可用。画像系统必须从\"静态快照\"升级为\"持续滚动评估\"。\n\n#### 时间桶指标 (TimeBucketMetrics)\n\n```go\n// backend-go/internal/autopilot/time_series.go\n\n// TimeBucketMetrics 按固定时间桶记录 endpoint 的质量快照\ntype TimeBucketMetrics struct {\n ChannelID int `json:\"channelId\"`\n MetricsKey string `json:\"metricsKey\"`\n BucketStart time.Time `json:\"bucketStart\"` // 桶起始时间(UTC,15 分钟对齐)\n BucketSize time.Duration `json:\"bucketSize\"` // 桶大小,默认 15 分钟\n\n // ── 该桶内的聚合指标 ──\n RequestCount int `json:\"requestCount\"`\n SuccessCount int `json:\"successCount\"`\n FailureCount int `json:\"failureCount\"`\n OverloadedCount int `json:\"overloadedCount\"` // 429\n StreamBreakCount int `json:\"streamBreakCount\"`\n EmptyResponseCount int `json:\"emptyResponseCount\"`\n\n P50LatencyMs int64 `json:\"p50LatencyMs\"`\n P95LatencyMs int64 `json:\"p95LatencyMs\"`\n P99LatencyMs int64 `json:\"p99LatencyMs\"`\n\n SuccessRate float64 `json:\"successRate\"`\n AvgInputTokens int `json:\"avgInputTokens\"`\n AvgOutputTokens int `json:\"avgOutputTokens\"`\n}\n```\n\n#### 质量趋势 (QualityTrend)\n\n```go\n// QualityTrend 描述 endpoint 质量的变化方向和幅度\ntype QualityTrend struct {\n MetricsKey string `json:\"metricsKey\"`\n DetectedAt time.Time `json:\"detectedAt\"`\n\n // ── 趋势方向 ──\n Direction TrendDirection `json:\"direction\"` // improving | stable | degrading | volatile\n\n // ── 对比基准 ──\n BaselineWindow string `json:\"baselineWindow\"` // \"7d\" | \"24h\" | \"1h\"\n BaselineSuccessRate float64 `json:\"baselineSuccessRate\"`\n CurrentSuccessRate float64 `json:\"currentSuccessRate\"`\n DeltaPercent float64 `json:\"deltaPercent\"` // 当前 vs 基准的变化百分比\n\n // ── 时段模式 ──\n HourlyPattern []HourlyQuality `json:\"hourlyPattern,omitempty\"` // 24 小时质量热力图\n PeakHours []int `json:\"peakHours,omitempty\"` // 质量低谷的小时列表\n OffPeakQuality QualityTier `json:\"offPeakQuality,omitempty\"`\n\n // ── 触发动作 ──\n ShouldReevaluate bool `json:\"shouldReevaluate\"` // 是否需要重新评估画像\n ReevalReason string `json:\"reevalReason,omitempty\"`\n}\n\ntype TrendDirection string\nconst (\n TrendImproving TrendDirection = \"improving\"\n TrendStable TrendDirection = \"stable\"\n TrendDegrading TrendDirection = \"degrading\"\n TrendVolatile TrendDirection = \"volatile\" // 忙闲交替剧烈\n)\n\n// HourlyQuality 某个小时的平均质量(UTC)\ntype HourlyQuality struct {\n Hour int `json:\"hour\"` // 0-23 UTC\n AvgSuccessRate float64 `json:\"avgSuccessRate\"`\n AvgP95Latency int64 `json:\"avgP95Latency\"`\n SampleCount int `json:\"sampleCount\"` // 该小时总请求数(跨多天)\n}\n```\n\n#### 趋势检测逻辑\n\n```go\n// backend-go/internal/autopilot/quality_trend_detector.go\n\ntype QualityTrendDetector struct {\n profileStore *ProfileStore\n}\n\n// DetectTrend 分析 endpoint 的质量趋势\nfunc (d *QualityTrendDetector) DetectTrend(\n channelID int,\n metricsKey string,\n currentTime time.Time,\n) QualityTrend {\n // 1. 取最近 7 天的时间桶数据\n buckets := d.profileStore.GetTimeBuckets(channelID, metricsKey, currentTime.Add(-7*24*time.Hour))\n\n // 2. 构建三个窗口的基准\n current1h := aggregateBuckets(buckets, currentTime.Add(-1*time.Hour), currentTime) // 最近 1 小时\n baseline24h := aggregateBuckets(buckets, currentTime.Add(-24*time.Hour), currentTime.Add(-1*time.Hour)) // 前 24 小时\n baseline7d := aggregateBuckets(buckets, currentTime.Add(-7*24*time.Hour), currentTime.Add(-24*time.Hour)) // 前 7 天\n\n // 3. 判断趋势方向\n // degrading:当前 1h 成功率比 24h 基准下降 > 15%,且比 7d 基准下降 > 10%\n // improving:当前 1h 成功率比 24h 基准上升 > 10%\n // volatile:最近 24h 内,小时成功率的标准差 > 20%\n // stable:其他情况\n\n // 4. 构建 24 小时质量热力图(UTC 小时 × 平均成功率)\n hourlyPattern := buildHourlyPattern(buckets)\n peakHours := findQualityTroughs(hourlyPattern, threshold: 0.70)\n\n // 5. 判断是否需要重新评估\n shouldReevaluate := false\n var reason string\n switch {\n case current1h.SuccessRate < baseline7d.SuccessRate * 0.75:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"成功率从 %.0f%% 降至 %.0f%%\", baseline7d.SuccessRate*100, current1h.SuccessRate*100)\n case current1h.P95Latency > baseline7d.P95Latency * 2:\n shouldReevaluate = true\n reason = fmt.Sprintf(\"p95 延迟从 %dms 升至 %dms\", baseline7d.P95Latency, current1h.P95Latency)\n case len(peakHours) > 0 && currentHourInList(currentTime, peakHours):\n // 当前正处于已知质量低谷\n reason = fmt.Sprintf(\"当前处于已知低谷时段 %v\", peakHours)\n // 不触发重评估,但标记为时段性降级\n }\n\n return QualityTrend{\n MetricsKey: metricsKey,\n DetectedAt: currentTime,\n Direction: trendDir,\n BaselineWindow: \"7d\",\n BaselineSuccessRate: baseline7d.SuccessRate,\n CurrentSuccessRate: current1h.SuccessRate,\n DeltaPercent: (current1h.SuccessRate - baseline7d.SuccessRate) / baseline7d.SuccessRate * 100,\n HourlyPattern: hourlyPattern,\n PeakHours: peakHours,\n ShouldReevaluate: shouldReevaluate,\n ReevalReason: reason,\n }\n}\n```\n\n#### 存储\n\n```sql\n-- 时间桶指标,7 天滚动\nCREATE TABLE time_bucket_metrics (\n channel_uid TEXT NOT NULL,\n channel_kind TEXT NOT NULL,\n channel_id INTEGER NOT NULL, -- 当前配置数组 index,仅作展示快照\n metrics_key TEXT NOT NULL,\n bucket_start TEXT NOT NULL, -- ISO 8601,15 分钟对齐\n bucket_json TEXT NOT NULL,\n PRIMARY KEY (channel_uid, channel_kind, metrics_key, bucket_start)\n);\nCREATE INDEX idx_time_bucket_metrics_kind_index ON time_bucket_metrics(channel_kind, channel_id, bucket_start);\n-- 自动清理:DELETE WHERE bucket_start < datetime('now', '-7 days')\n```\n\n#### 对调度的影响\n\n```text\n场景 │ 调度行为\n──────────────────────────────────┼──────────────────────────────────────────\ntrend=stable │ 正常,使用当前画像\ntrend=improving │ 正常,画像质量档可能即将升级\ntrend=degrading + shouldReevaluate│ 降权,同时触发画像重评估\ntrend=volatile │ FastDecay 系数降低(衰减更快,回升更慢)\n当前处于 peakHours 低谷 │ 该 endpoint 的 SpeedTier 临时降一档,不标记 dead\n忙时质量低 + 闲时质量高 │ SmartRouter 在忙时自动倾向非低谷 endpoint\n```\n\n#### 与 HealthAnalyzer 的集成\n\n趋势检测是 HealthAnalyzer 的输入信号之一,不是独立判定:\n\n```text\nHealthState 判定 = L1 被动指标 + 趋势信号 + L2/L3 探测(如需要)\n\n具体:\n - L1 成功率 < 50% → 直接 dead(不看趋势)\n - L1 成功率 50-80% + trend=degrading → degraded,标记 shouldReevaluate\n - L1 成功率 80-95% + trend=degrading + 连续 3 个桶恶化 → degraded\n - L1 成功率 > 95% + trend=degrading → 保持 healthy,但在 UI 显示\"质量下降趋势\"\n - L1 成功率正常 + 当前处于 peakHours → 保持 healthy,但 SpeedTier 临时降档\n```\n\n#### 更新频率\n\n```text\n时间桶写入:每次请求后增量更新 15 分钟桶(内存聚合,桶结束时刷 SQLite)\n趋势计算:每 15 分钟(桶结束时触发),不额外查询\n小时热力图:每小时更新一次(24 个 UTC 小时 × 过去 7 天的数据)\n```\n\n## 4. 核心组件设计\n\n### 4.1 组件总览\n\n```text\n┌──────────────────────────────────────────────────────────────┐\n│ Channel Autopilot │\n│ │\n│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │\n│ │ Discovery │ │ Profiler │ │ HealthAnalyzer │ │\n│ │ (协议发现 │→│ (画像生成 │→│ (健康诊断/分层) │ │\n│ │ 模型发现) │ │ 能力推导) │ │ │ │\n│ └───────────┘ └───────────┘ └──────────┬───────────────┘ │\n│ │ │ │ │\n│ │ │ ┌──────────▼───────────┐ │\n│ │ │ │ QualityTrendMonitor │ │\n│ │ │ │ (时序趋势/忙闲检测) │ │\n│ │ │ └──────────┬───────────┘ │\n│ │ │ │ │\n│ └──────────────┴────────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ ProfileStore │ ← SQLite + 内存缓存 │\n│ │ (endpoint 级) │ + TimeBucket 指标 │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ SmartRouter │ ← 注入 CandidateFilter │\n│ │ (任务分类→标签 │ │\n│ │ 匹配→排序) │ │\n│ └─────────────────┘ │\n│ ▼ │\n│ ┌─────────────────┐ │\n│ │ Scheduler │ (现有,不修改核心链路) │\n│ │ SelectChannel │ │\n│ └─────────────────┘ │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### 4.2 Discovery — 协议与模型发现\n\n**职责**:对每个 KeyEndpoint 独立探测协议、发现模型、推荐映射。\n\n**⚠️ 关键:探测粒度是 endpoint 级,不是 channel 级**\n\n同一 channel 的不同 key 可能属于不同分组,返回不同模型列表。必须逐 endpoint 探测。\n\n**触发时机**:\n1. 渠道首次添加(`autoManaged: true` 时)\n2. 手动触发「重新发现」\n3. 定时刷新(每天一次,但逐 endpoint 轮转,不并发)\n4. **分组变更检测触发**(model_not_found 或模型列表哈希变化)\n\n**流程**:\n\n```text\n添加渠道(baseURL + key[])\n │\n ├─ 遍历每个 (baseURL, apiKey) 对:\n │ │\n │ ├─ 1. 协议探测:对单个 endpoint 探测 messages/responses/chat/gemini\n │ │ └─ 复用 capability_test_runner.executeModelTest\n │ │\n │ ├─ 2. 模型发现:GET /v1/models(用该 key)\n │ │ └─ 复用 channel_discovery.discoverTransientModels\n │ │ └─ 失败时用内置候选模型列表回退\n │ │\n │ ├─ 3. 分组变更检测:对比模型列表哈希\n │ │ └─ GroupChangeDetector.CheckGroupChange\n │ │ └─ 如果变更 → 标记 stale,重新生成 ModelProfile\n │ │\n │ ├─ 4. 模型选择:从发现的模型中选 Strong/Primary/Fast 三档\n │ │ └─ 复用 channel_discovery.selectDiscoveryModels\n │ │\n │ ├─ 5. 能力探测:对选中模型做硬失败检测(HTTP 错误/解析失败)\n │ │ └─ 复用 channel_discovery.runDiscoveryToolCallProbe\n │ │ └─ 复用 channel_discovery.runDiscoveryVisionProbe\n │ │\n │ ├─ 6. 映射推荐:根据协议类型生成该 endpoint 的 modelMapping\n │ │\n │ └─ 7. 生成 KeyEndpointProfile + ModelProfile[]\n │\n └─ 8. 聚合:从所有 endpoint 画像生成 ChannelProfile\n```\n\n**endpoint 间差异处理**:\n\n```text\n场景:同一 channel 的 key-A 返回 [opus, sonnet, haiku],key-B 返回 [gpt-5.5, gpt-5.4-mini]\n │\n ├─ KeyEndpointProfile-A: qualityTier=premium, models=[opus, sonnet, haiku]\n ├─ KeyEndpointProfile-B: qualityTier=premium, models=[gpt-5.5, gpt-5.4-mini]\n │\n └─ ChannelProfile:\n - qualityTier = premium(取最佳 endpoint)\n - EndpointInconsistencies = [{\"models\", \"key-A: Claude系列, key-B: GPT系列\"}]\n - UI 显示:⚠️ 不同 Key 提供的模型系列不同\n```\n\n**与现有 Channel Discovery 的关系**:\n\n现有 `POST /channel-discovery` 是一个\"预览\"接口,返回推荐但不自动应用。Autopilot 复用其核心逻辑,但:\n- **逐 endpoint 探测**,而非整个 channel 一次\n- 自动写入每个 endpoint 的 `modelMapping`、`supportedModels`、兼容开关\n- 自动生成 `KeyEndpointProfile` + `ModelProfile` 记录\n- 对 `autoManaged` 渠道静默执行,对非 auto 渠道提供「建议应用」按钮\n\n#### 4.2.1 内置模型清单 (Builtin Models Manifest)\n\n**问题**:部分官方订阅 plan 的入口没有正确的 models 接口——要么不存在(404)、要么需要额外权限、要么返回与订阅实际可用模型不符的列表(典型:Claude 订阅 OAuth 入口、Codex plan 入口、部分官方企业网关)。对这些 baseURL 做模型发现会失败或产出错误画像。\n\n**方案**:CCX 内置「已知官方 baseURL → 模型清单」映射表,命中时直接返回内置内容,完全跳过上游 models 接口:\n\n```go\n// backend-go/internal/config/builtin_models_manifest.go(生成式,随模型注册表更新)\ntype BuiltinModelsManifest struct {\n BaseURLPattern string `json:\"baseUrlPattern\"` // 精确 host 或前缀匹配,如 \"api.anthropic.com\"\n ServiceType string `json:\"serviceType\"` // messages/responses/…\n PlanHint string `json:\"planHint,omitempty\"` // 订阅类型提示(如 \"claude_subscription\")\n ModelIDs []string `json:\"modelIds\"` // 该入口实际可用的模型清单\n DisableProbe bool `json:\"disableProbe\"` // true 时 Discovery 跳过 GET /v1/models\n}\n```\n\n**两个生效位置**:\n\n```text\n1. Discovery(§4.2 流程第 2 步之前):\n baseURL 命中 manifest 且 DisableProbe=true\n → 跳过 GET /v1/models,直接用 ModelIDs 生成 ModelProfile[]\n → ProfileSource = \"builtin_manifest\",不再因 models 接口失败而误判 misconfigured\n\n2. /v1/models 代理响应(现有 Models API):\n 客户端向 CCX 请求 /v1/models 时,聚合结果中该渠道的贡献\n 直接取内置清单,不向上游发起 models 请求\n (渠道显式配置了 supportedModels 时仍以用户配置优先)\n```\n\n**维护与覆盖顺序**:\n\n```text\n渠道显式 supportedModels(用户手动) > 内置 manifest > 上游 models 接口实探 > 内置候选模型回退\n```\n\nmanifest 与模型注册表同源生成(`generated_model_registry.go` 同级),新模型发布时随注册表一起更新;HealthAnalyzer 对命中 manifest 的 endpoint 不将「models 接口 404/403」计入 misconfigured 信号。\n\n### 4.3 Profiler — 画像生成器\n\n**职责**:综合模型注册表、探测结果、运行时指标,生成 ChannelProfile 和 ModelProfile。\n\n**推导规则**:\n\n#### QualityTier 推导\n\n```text\n优先级 1:模型注册表 BuiltinUpstreamModelCapabilities 中的模型族\n claude-opus-* / gpt-5.5 / gpt-5.4 → premium\n claude-sonnet-* / gpt-5.3-codex → high\n claude-haiku-* / gpt-5.4-mini → normal\n 其他 → low\n\n优先级 2:渠道级 LowQuality 标记\n lowQuality: true → 最高 normal\n\n优先级 3:capability-test 探测质量\n 探测响应长度 > 100 tokens 且无截断 → 保持注册表推导\n 探测失败或空响应 → 降一档\n```\n\n#### StabilityTier 推导\n\n```text\n基于最近 1 小时指标:\n 成功率 >= 95% 且 429 率 < 5% → stable\n 成功率 >= 80% 且 429 率 < 20% → normal\n 其他 → unstable\n\n额外信号(任一命中则降级):\n 连续失败 >= 5 次 → 最高 normal\n 熔断器 open → unstable\n 最近成功 > 6 小时前 → unstable\n```\n\n#### SpeedTier 推导\n\n```text\n基于最近 100 次请求的 p95 首 token 延迟:\n < 500ms → fast\n < 2000ms → normal\n >= 2000ms → slow\n\n冷启动:无足够数据时用 capability-test 的 ProbeLatencyMs\n < 800ms → fast\n < 3000ms → normal\n >= 3000ms → slow\n```\n\n#### CostTier 推导\n\n```text\n优先级 1:用户手动成本画像\n APIKeyConfig.GroupMultipliers + RechargeMultiplier → 计算真实有效成本\n costHint → 仅作为无价格数据时的粗粒度 fallback\n\n优先级 2:模型注册表中的 Pricing 字段\n Input/Output 都是 0 → free\n EffectiveInput < $1/M 且 EffectiveOutput < $5/M → cheap\n EffectiveInput < $10/M 且 EffectiveOutput < $30/M → normal\n 其他 → expensive\n\n优先级 3:运行时行为启发(低置信度)\n 频繁 429 且无 Retry-After → 可能是免费/低配额,标记 cheap\n 频繁 402/余额不足 → 有成本,标记 normal\n```\n\n#### EffectiveCost 推导\n\n只用 `CostTier` 不足以做省钱调度。Autopilot 需要在满足能力和质量下界后,按 endpoint 的真实有效成本排序:\n\n```text\nbaseInputCost = ModelPricing.InputCacheMissPrice\nbaseOutputCost = ModelPricing.OutputPrice\n\ngroupMultiplier = matchGroupMultiplier(requestModel, APIKeyConfig.GroupMultipliers, default=1.0)\nrechargeMultiplier = APIKeyConfig.RechargeMultiplier\nif rechargeMultiplier <= 0:\n rechargeMultiplier = 1.0\n\neffectiveMultiplier = groupMultiplier / rechargeMultiplier\neffectiveInputCost = baseInputCost * effectiveMultiplier\neffectiveOutputCost = baseOutputCost * effectiveMultiplier\n```\n\n例子:\n\n```text\nclaude-opus 官价 input=$15/M, output=$75/M\nkey-A: groupMultiplier=0.8, rechargeMultiplier=1.0 → effective=0.8x\nkey-B: groupMultiplier=1.0, rechargeMultiplier=2.0 → effective=0.5x\n\n在二者健康、上下文、能力和质量都满足时,优先 key-B。\n```\n\n约束:\n- 成本优化只能在 `CapabilityFloor`、`QualityTier`、上下文窗口、vision/tool/reasoning 需求全部满足之后执行。\n- 不允许为了省钱把 supervisor / premium 请求降级到不满足 `MinQualityTier` 的模型或 endpoint。\n- 对低置信度成本画像,只能作为 tie-breaker,不能覆盖显式质量策略。\n\n#### 能力标签推导\n\n**⚠️ 原则:只做硬失败检测,不判定软质量问题**\n\n识图/工具/reasoning 的\"虚标\"(渠道声称支持但实际返回垃圾)可靠判定很难。策略是:\n\n- **硬失败**(可自动检测):调用报错、格式错误、HTTP 错误码、解析失败\n- **软质量问题**(留给人工):答非所问、内容质量低、thinking 输出无意义\n\n```text\nSupportsVision:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"vision\"] == true\n 2. 且 NoVision != true\n 3. 且 (NoVisionModels 不含该模型 || VisionFallbackModel 已设置)\n ── 可选验证(L3 探测)──\n 4. vision probe 返回 HTTP 200 且响应可解析(不要求内容质量)\n 5. 如果 probe 返回 400/415/unsupported → 明确标记 SupportsVision=false\n\nSupportsToolCalls:\n ── 硬条件(自动判定)──\n 1. 注册表 Capabilities[\"toolCalls\"] == true\n ── 可选验证(L3 探测)──\n 2. tool_call probe 返回 HTTP 200 且响应含合法 tool_use block\n 3. 如果 probe 返回 400/tool_not_found → 明确标记 SupportsToolCalls=false\n\nSupportsReasoning:\n ── 硬条件(自动判定)──\n 1. 注册表 ThinkingMode 非空\n ── 可选验证(L3 探测)──\n 2. reasoning probe 返回 HTTP 200 且响应含 thinking/reasoning block\n 3. 如果 probe 返回 400/thinking_not_supported → 明确标记 SupportsReasoning=false\n\nSupportsLongCtx:\n 1. ContextWindowTokens >= 200_000(来自注册表,无需探测)\n 2. 或注册表 Supports1M == true\n```\n\n**虚标处理**:如果 L1 被动信号显示某渠道的 vision/tool/reasoning 请求**成功但用户标记为\"结果差\"**,系统不自动关闭标签,而是在 UI 上显示「⚠️ 用户反馈能力可能不准确」,允许人工 override。这避免了系统在\"质量差\"和\"不支持\"之间误判。\n\n#### 识图能力分层与向现有代码的过渡\n\n现有代码中识图能力由渠道级手动配置控制(`NoVision`、`NoVisionModels`、`VisionFallbackModel`),Autopilot 逐步引入自动画像,两者按优先级共存:\n\n```text\nLayer 0 — 手动显式配置(现有,始终最高优先级)\n NoVision=true → SupportsVision 强制为 false,自动画像不覆盖\n NoVisionModels=[...] → 指定模型不支持视觉,其余模型仍可走自动画像\n VisionFallbackModel → 主模型无视觉时的回退,Phase 1 继续生效\n\nLayer 1 — 自动画像 shadow(Phase 1,只展示不调度)\n endpoint 级 SupportsVision 由 L3 probe 生成,写入 KeyEndpointProfile\n NoVision=nil 且注册表 Capabilities[\"vision\"]=true → shadow 展示建议值\n 调度仍走现有 shouldReserveVisionChannelForText watermark 逻辑\n\nLayer 2 — 自动画像影响 endpoint 排序(Phase 2)\n EndpointAttemptPolicy 使用 KeyEndpointProfile.SupportsVision 过滤候选\n NoVision=nil 的渠道:优先使用画像值;画像置信度 < 0.7 时回退 Layer 0 规则\n shouldReserveVisionChannelForText 继续保护视觉渠道不被文本请求耗尽\n\nLayer 3 — 完全自动(Phase 3)\n SupportsVision 由持续 L1+L3 信号维护,NoVision 降级为人工 override 入口\n VisionFallbackModel 可由 ModelResolver 自动选取满足视觉下界的备选模型\n```\n\n兼容规则(落地时必须测试):\n\n```text\nNoVision=true → SupportsVision 永远 false,忽略所有画像\nNoVision=false(显式关闭) → SupportsVision 信任用户,画像不覆盖\nNoVision=nil(未配置) → Phase 1 用注册表+probe;Phase 2+ 用画像\nNoVisionModels 包含请求模型 → 该模型 SupportsVision=false,不影响其他模型\n画像置信度 < 0.7 → 回退到注册表 Capabilities[\"vision\"] 判断\nprobe 返回 400/415 → 标记 SupportsVision=false,置信度=0.9\nprobe 成功但内容不可解析 → 不更新画像,保持现有状态\n```\n\n### 4.4 HealthAnalyzer — 健康诊断器\n\n**职责**:持续分析渠道健康,生成 HealthState 和证据。\n\n**⚠️ 核心原则:被动优先 (Passive-First)**\n\n30-40 渠道 × 多模型的主动探测有 quota 成本,且白嫖渠道本身就抖。诊断信号分三层,成本递增:\n\n| 层级 | 信号来源 | 成本 | 频率 | 适用场景 |\n|------|---------|------|------|---------|\n| L1 被动信号 | 真实请求的 MetricsManager | 零 | 实时/每次请求 | **默认层**,所有健康判定的主要依据 |\n| L2 轻量探测 | 单模型 ping(最小 prompt) | 极低 | cooldown 复测 | L1 无数据(新渠道/长时间无请求) |\n| L3 深度探测 | capability-test(多模型多协议) | 中 | 手动/每天 | 新渠道首次画像、用户主动触发、misconfigured 修复后 |\n\n**分析周期**:\n- L1 被动:每次请求后增量更新(复用 MetricsManager 已有的 RecordSuccess/RecordFailure)\n- L1 聚合:每 5 分钟做一次滑动窗口聚合,更新 ChannelProfile\n- L2 探测:仅在以下条件触发:\n - 渠道状态为 `unknown` 且添加超过 10 分钟\n - `limited`/`dead` 的 cooldown 到期\n - L1 数据不足(最近 1 小时请求数 < 3)\n- L3 深度:仅在以下条件触发:\n - 用户手动点击「重新探测」\n - 新渠道 `autoManaged` 首次添加\n - `misconfigured` 状态修复后\n\n**被动信号指标**(全部来自 MetricsManager 现有数据,无需额外请求):\n\n```text\n成功/失败率 → MetricsManager.CalculateChannelFailureRateMultiURL\n429 率 → FailureClass=overloaded 计数 / 总请求数\n断流率 → ChannelLog.Status=\"streaming\" 但无 \"completed\" 的比率\n空响应率 → ChannelLog 中 DurationMs > 0 但 usage.InputTokens=0 的比率\np95 延迟 → ChannelLog.DurationMs 的 p95 分位\n连续失败 → MetricsManager 滑动窗口 consecutiveFail\n最后成功时间 → MetricsManager.GetChannelAggregatedMetrics.LastSuccessAt\n熔断器状态 → MetricsManager.GetChannelCircuitStateMultiURL\nKey 健康 → DisabledAPIKeys 数量 vs 总 Key 数量\n```\n\n**诊断逻辑**(见第 6 章详细设计)。\n\n### 4.5 RateLimitDiscoverer — 上游限额自动发现\n\n**职责**:在用户未显式配置 `rateLimitRpm` / key 级 `rateLimitRpm` 等限额时,为 endpoint 推导保守的运行态 RPM/TPM/RPD/并发限额,减少 429 和上游账号池冷却。\n\n核心原则:\n- **显式配置永远优先**:只要 channel 或 `APIKeyConfig` 设置了 `RateLimitRPM`,自动发现只做展示,不覆盖 limiter。\n- **被动优先**:优先从真实响应头学习,不主动压测上游。\n- **保守收敛**:无明确 header 时使用 AIMD(additive increase, multiplicative decrease)估算,只下调快、上调慢。\n- **endpoint 粒度**:学习结果绑定 `metricsKey`,必要时映射到 key/quota scope limiter;不能只写 channel 级限速。\n\n#### 4.5.1 信号来源\n\n```text\n优先级 │ 来源 │ 动作\n───────┼──────────────────────────────┼────────────────────────────────────\n1 │ 用户显式 RateLimitRPM │ 直接使用,自动发现不覆盖\n2 │ x-ratelimit-limit-requests │ 计算窗口内请求上限,换算 RPM/RPD\n3 │ x-ratelimit-limit-tokens │ 计算窗口内 token 上限,换算 TPM\n4 │ x-ratelimit-remaining/reset │ 估算当前消耗速度与重置窗口\n5 │ anthropic-ratelimit-* │ 同上,按 reset 时间换算 request/token limit\n6 │ 并发/队列/断流信号 │ 降低 MaxConcurrent,再观察 RPM/TPM\n7 │ Retry-After + 429/5xx │ 进入 cooldown,并降低估算 limit\n8 │ 无 header 的 429 比率 │ AIMD 降低估算 limit\n9 │ 长时间成功且低延迟 │ AIMD 缓慢提高估算 limit\n```\n\n现有 `ratelimit.ChannelLimiter.ApplyUpstreamHints` 已能解析 `Retry-After`、Anthropic remaining/reset、OpenAI remaining/reset 并施加 cooldown。Autopilot 在此基础上新增“可解释的运行限额推导”,不要复制现有 cooldown 逻辑。\n\n#### 4.5.2 学习状态\n\n```go\n// backend-go/internal/autopilot/rate_limit_discovery.go\n\ntype RateLimitProfile struct {\n ChannelUID string `json:\"channelUid\"`\n ChannelKind string `json:\"channelKind\"`\n MetricsKey string `json:\"metricsKey\"`\n Scope string `json:\"scope\"` // channel | key: | quota:\n EstimatedRPM int `json:\"estimatedRpm\"`\n EstimatedTPM int `json:\"estimatedTpm,omitempty\"`\n EstimatedRPD int `json:\"estimatedRpd,omitempty\"`\n WindowSeconds int `json:\"windowSeconds\"`\n MaxConcurrent int `json:\"maxConcurrent,omitempty\"`\n Source string `json:\"source\"` // manual | header | passive_aimd\n Confidence float64 `json:\"confidence\"`\n Last429At *time.Time `json:\"last429At,omitempty\"`\n UpdatedAt time.Time `json:\"updatedAt\"`\n}\n```\n\n存储:\n- 可放入 `KeyEndpointProfile.profile_json`;如果后续需要历史趋势,再独立建 `rate_limit_profiles` 表。\n- 不写明文 key,只通过 `metricsKey` 和 limiter scope 关联。\n\n#### 4.5.3 推导规则\n\n```text\nheader 明确给 limit:\n estimatedRPM = normalize(limit, reset/window)\n confidence = 0.9\n\n只有 remaining/reset:\n observedRate = requests_since_last_reset / elapsed\n estimatedRPM = min(current_estimate, inferred_window_capacity)\n confidence 逐次成功解析后提升,最高 0.75\n\n429 + Retry-After:\n cooldownUntil = now + Retry-After\n estimatedRPM = max(minRPM, floor(current_estimate * 0.5))\n confidence = max(confidence, 0.7)\n\n429 无 Retry-After:\n estimatedRPM = max(minRPM, floor(current_estimate * 0.7))\n confidence = max(confidence, 0.5)\n\n连续稳定成功:\n 每 10 分钟最多 +10%,且需要最近 15 分钟无 429\n```\n\n默认边界:\n- `minRPM=1`,防止估算为 0 后永久不可用。\n- `maxAutoRPM` 默认不超过 120,除非 header 明确给出更高 limit;避免自动学习把免费站打爆。\n- `maxAutoTPM`、`maxAutoRPD`、`maxAutoConcurrent` 也是硬上限;没有明确 header 时不能超过配置上限。\n- 对流式请求同时学习 `MaxConcurrent`:若出现频繁排队、断流或 429,先降并发,再降 RPM。\n\n#### 4.5.4 应用到运行态 limiter\n\n```text\nif manual channel/key RateLimitRPM/TPM/RPD/MaxConcurrent > 0:\n 使用手动配置;profile 只展示 header/passive 发现值\nelse if discovered EstimatedRPM > 0 and confidence >= threshold:\n 将 discovered RPM/TPM/RPD/MaxConcurrent 作为 runtime limiter 配置\nelse:\n 保持现有默认;只依赖 Retry-After cooldown 和负载软跳过\n```\n\n落地时不要自动写入 `config.json`。UI 可以显示“建议设置 RPM=xx”,由用户一键采纳;运行态 limiter 可以使用 profile 中的估算值,服务重启后从 ProfileStore 恢复。\n\n### 4.6 SmartRouter — 智能路由注入\n\n**职责**:根据请求画像 + 渠道画像生成一次请求的路由计划。路由计划分两层:\n\n1. **Channel 层**:通过 `CandidateFilter` 过滤/重排 channel。\n2. **Endpoint 层**:通过 `EndpointAttemptPolicy` 过滤/重排同一 channel 内的 baseURL + key,并提供 endpoint 级模型覆盖。\n\n只靠 `CandidateFilter` 不足以实现 endpoint 级画像,因为现有 `CandidateFilterFunc` 只能返回 `[]scheduler.ChannelInfo`,而实际 baseURL/key 选择发生在 `common.TryUpstreamWithAllKeys` 内部。\n\n#### 4.6.1 请求路由计划\n\n```go\n// backend-go/internal/autopilot/smart_router.go\n\ntype RequestRoutingPlan struct {\n RequestProfile *RequestProfile\n CandidateFilter scheduler.CandidateFilterFunc\n EndpointPolicy *EndpointAttemptPolicy\n}\n\ntype SmartRouter struct {\n profileStore *ProfileStore\n modelResolver *ModelResolver\n intentStore *ManualRoutingIntentStore\n config *SmartRoutingConfig\n}\n\n// BuildPlan 为每次请求构建完整路由计划。\nfunc (r *SmartRouter) BuildPlan(profile *RequestProfile) *RequestRoutingPlan {\n return &RequestRoutingPlan{\n RequestProfile: profile,\n CandidateFilter: r.buildCandidateFilter(profile),\n EndpointPolicy: r.buildEndpointPolicy(profile),\n }\n}\n```\n\n#### 4.6.2 EndpointAttemptPolicy\n\n```go\n// backend-go/internal/autopilot/endpoint_policy.go\n\ntype EndpointCandidate struct {\n ChannelUID string\n ChannelKind string\n MetricsKey string\n BaseURL string\n KeyMask string\n MappedModel string\n MappedEffort EffortLevel // 自动映射的 effort 级别(空=未决定)\n MappedEffortDecided bool // effort 是否由 Autopilot 决定\n EndpointUID string // 稳定 endpoint ID\n Score float64\n Reason string\n}\n\ntype EndpointAttemptPolicy struct {\n RequestModel string\n ByChannelUID map[string][]EndpointCandidate // 已按优先级排序\n FailOpen bool // true: 无画像时回退现有 key/baseURL 轮转\n\n // ResolvedModelByEndpointUID(旧 API):仅返回模型名,向后兼容。\n // ResolvedTargetByEndpointUID(新 API):返回 model + effort 原子决策。\n // 两者从同一份 targetByUID 派生,避免两个真值源。\n ResolvedModelByEndpointUID func(endpointUID string) string\n ResolvedTargetByEndpointUID func(endpointUID string) *ResolvedRouteTarget\n}\n```\n\n执行契约:\n- `CandidateFilter` 只负责 channel 级顺序,不直接选择 key/baseURL。\n- `EndpointAttemptPolicy` 通过新的 `common.TryUpstreamOption` 注入 `TryUpstreamWithAllKeys`。\n- `TryUpstreamWithAllKeys` 在进入 baseURL/key 双层循环前,先按 policy 过滤和重排 `urlResults`。\n- `selectAttemptAPIKey` 需要新增 policy-aware 分支:在 `keypool.CandidatesForModel` 结果上应用 endpoint 候选顺序、健康状态、FastDecay 分数和模型下界。\n- 若 policy 对当前 channel 没有候选且 `FailOpen=true`,保持现有 failover 行为;若 `FailOpen=false`,跳过该 channel。\n\n建议的最小接口:\n\n```go\n// backend-go/internal/handlers/common/upstream_failover.go\n\nfunc WithEndpointAttemptPolicy(policy *autopilot.EndpointAttemptPolicy) TryUpstreamOption\n```\n\n这样可以复用现有 `TryUpstreamWithAllKeys` 的熔断、限流、拉黑、日志、URL warmup 行为,只在候选排序和模型覆盖点插入 autopilot。\n\n#### 4.6.2a TryUpstreamWithAllKeys 改造细节\n\n**改造目标**:最小侵入地将 `EndpointAttemptPolicy` 注入现有双层循环,不破坏熔断/限流/拉黑/warmup 等现有保护行为。\n\n改造后的执行顺序(baseURL × key 双层循环内):\n\n```text\n原始 urlResults(按配置顺序)\n │\n ├─ 1. [NEW] policy.FilterURLs(urlResults)\n │ → 移除画像中 HealthState=dead 的 baseURL\n │ → FailOpen=true 时:过滤后为空则回退全量 urlResults\n │\n ├─ 2. [NEW] policy.SortURLs(filtered)\n │ → 按 EndpointCandidate.Score 降序排列 baseURL\n │\n └─ 遍历每个 baseURL:\n │\n ├─ 3. [EXISTING] URL warmup 检查(不变)\n ├─ 4. [EXISTING] keypool.CandidatesForModel(baseURL, model)\n │\n ├─ 5. [NEW] policy.FilterKeys(baseURL, candidates)\n │ → 移除画像中 FastDecay.EffectiveScore < threshold 的 key\n │ → 移除处于 cooldown 中的 key(已有逻辑,policy 可追加条件)\n │ → FailOpen=true 时:过滤后为空则回退全量 candidates\n │\n ├─ 6. [NEW] policy.SortKeys(baseURL, filtered)\n │ → 按 EndpointCandidate.Score 降序排列 key\n │ → 同分时:成本低的优先,信任等级高的作为 tie-breaker\n │\n └─ 遍历每个 key:\n ├─ 7. [EXISTING] 熔断、限流、拉黑检查(不变)\n ├─ 8. [EXISTING] 发起上游请求(不变)\n ├─ 9. [NEW] 请求成功 → NotifySuccess(baseURL, key) → 更新亲和\n └─ 10.[NEW] 请求失败 → NotifyFailure(baseURL, key) → 更新 FastDecay\n```\n\n`MappedModel` 的注入点在步骤 8 之前:\n\n```go\n// 如果 policy 对当前 (baseURL, key) 提供了 MappedModel,替换请求体中的 model 字段\nif candidate := policy.GetCandidate(baseURL, key); candidate != nil && candidate.MappedModel != \"\" {\n requestBody.Model = candidate.MappedModel\n // ChannelLog 中记录 originalModel 和 mappedModel\n}\n```\n\n**FailOpen 保证**:任何 policy 操作出错(panic recover、nil policy、store 读取失败),立即退出 policy 路径,回到原有顺序遍历,不中断请求。\n\n#### 4.6.3 与现有手动控制的优先级\n\nSmartRouter 不能破坏用户显式控制:\n\n```text\n最高优先级:ManualRoutingIntent(model_trial/channel_trial/endpoint_trial/session_pin)\n显式控制:X-Channel 指定渠道、手动序列 override、promotion\n中间层级:SmartRouter channel 重排/过滤\n底层约束:协议/鉴权/上下文/vision/tool/reasoning、熔断、限速、key/baseURL failover\n```\n\n当前 `CandidateFilter` 在 `SelectChannelWithOptions` 中执行得早于 X-Channel、manual override、promotion。Phase 2 落地时必须二选一:\n\n1. 调整 `SelectChannelWithOptions` 的阶段顺序,让显式控制先定位候选,再让 SmartRouter 只处理剩余默认调度。\n2. 保持现有顺序,但给 `CandidateFilter` 传入 `ProtectedChannels`,SmartRouter 对受保护 channel 只能降权,不能过滤。\n\n推荐方案 1,行为更符合“显式用户意图优先”。\n\n#### 4.6.4 人工意图执行语义\n\n`ManualRoutingIntent` 不是普通 priority,也不是永久 modelMapping。它是短期路由补丁:\n\n```text\n请求进入\n→ 解析 request/session/model/agent role\n→ 查找匹配的 active ManualRoutingIntent\n→ 若命中:\n - 构建 protected channel/endpoint/model candidate\n - 仍执行硬约束校验\n - 按 TTL/MaxRequests/MaxEstimatedCost 扣减预算\n - 请求失败且 FallbackOnFailure=true 时回到默认 Autopilot plan\n→ 若未命中:走默认 SmartRouter\n```\n\n场景规则:\n- **测试 `fable-5`**:创建 `model_trial`,指定 `model=fable-5`,可选指定 `channelUid/metricsKey`;模型未知时只允许 request-scoped 透传或 mappedModel 试用,不自动写入全局 `modelMapping`。\n- **先用某个公益站**:创建 `channel_trial` 或 `endpoint_trial`,限制 `taskClasses=[worker, lightweight]`、`trafficPercent`、`expiresAt` 和 `MaxRequests`;成功后用户可手动推广为常规策略。\n- **会话级排障**:创建 `session_pin`,只影响 `sessionId`,用于确认某个渠道是否兼容当前客户端。\n- **主代理保护**:默认不允许 third-tier 公益站 trial 覆盖 supervisor,除非用户在 UI 明确选择主代理试用并设置短 TTL。\n\n观测要求:\n- 路由 trace 必须标注 `intentUid`、命中原因、预算剩余、是否 fallback。\n- trial 产生的画像单独标注 `manual_trial`,驾驶舱显示试用结果摘要。\n- 试用结束后不自动改变长期策略;只给出「保存为 modelMapping」「提升渠道权重」「加入常规池」等显式操作。\n\n#### 4.6.5 与现有 CandidateFilter 的兼容\n\n现有 handler 自有 filter(例如 vectors 的 embedding 维度过滤)必须与 SmartRouter 组合,而不是互相覆盖:\n\n```text\nactive/model/context 过滤\n→ protected manual controls\n→ SmartRouter CandidateFilter(channel 粗筛/重排)\n→ handler CandidateFilter(协议/业务硬约束)\n→ priority/affinity/promotion fallback\n→ TryUpstreamWithAllKeys + EndpointAttemptPolicy(endpoint 细选)\n```\n\n如果某个 handler 已经传入 `CandidateFilter`,公共 failover 外壳需要提供 `ComposeCandidateFilters`,按顺序合并多个 filter。\n\n### 4.7 TrustedRoutingAdvisor — 可信模型路由辅助\n\n**职责**:使用一等官方 API/token plan 或本地模型为 SmartRouter 提供隐私边界内的路由 hint。它不是最终裁判。\n\n允许的 advisor 执行面:\n- `OriginTier=first`:官方 API / 官方 token plan,可用于隐私敏感的路由判定。\n- `OriginTier=local`:用户自托管本地运行时,可用于隐私敏感的路由判定。\n\n禁止的 advisor 执行面:\n- `OriginTier=second`:中转站可以执行用户明确发起的模型请求,但不能接收额外上下文做路由判定。\n- `OriginTier=third`:公益站可以作为低风险候选执行上游,但不能作为 routing advisor。\n- `OriginTier=unknown`:未知来源只允许观察,不参与 AI 判定。\n\n支持的本地运行时:\n- `ollama`:优先走 OpenAI-compatible 接口;不支持时用 native adapter。\n- `lmstudio`:按 OpenAI-compatible `/v1` adapter 接入。\n- `llama_server`:按 OpenAI-compatible `/v1` adapter 接入。\n- `openai_compatible`:用户自定义本地 baseURL。\n\n#### 4.7.1 讨论结论转成设计约束\n\n用户贴出的讨论里,核心争议是:用较弱模型判断“哪些子任务只有 Fable 做得到”本身接近强模型能力,弱模型容易不知道自己不知道什么;而 Fable 的优势常来自全局 context 和长链推理,这类任务通常不适合自动切分再合并。\n\n因此本方案不做“弱模型动态拆任务省 Fable”的通用机制。务实策略是:\n- 人凭经验预先定义任务类别和路由规则,工具负责执行路由。\n- advisor 只判断**已定义分类**,例如“日志分类/标题/格式化/简单摘要/低风险 worker”。\n- advisor 只能在高置信、低风险、可回退场景做 demotion 建议;不让它判定强模型“不需要”。\n- 对架构设计、跨文件重构、长上下文综合、模糊需求澄清、关键业务决策,默认保持 high/premium 路由。\n- 任何自动切分-合并必须来自用户显式模板;没有模板时不自动把全局任务拆给本地模型。\n\n#### 4.7.2 RoutingHint 契约\n\n```go\ntype TrustedRoutingHint struct {\n TaskClass TaskClass `json:\"taskClass\"`\n ComplexityTier string `json:\"complexityTier\"` // trivial | routine | complex | unknown\n SuggestedMinQualityTier QualityTier `json:\"suggestedMinQualityTier\"`\n AllowLocalCandidate bool `json:\"allowLocalCandidate\"`\n AllowCheapRemote bool `json:\"allowCheapRemote\"`\n NeverDemote bool `json:\"neverDemote\"`\n Confidence float64 `json:\"confidence\"`\n Reasons []string `json:\"reasons\"`\n}\n```\n\n执行规则:\n- `NeverDemote=true` 时,SmartRouter 不能因为成本或 advisor 建议降低质量下界。\n- `AllowLocalCandidate=true` 只表示本地模型可进入候选集,仍需满足 context/tool/vision/reasoning 硬约束。\n- `Confidence < threshold` 或 advisor 超时,忽略 hint,回到默认 SmartRouter。\n- advisor 的 hint 先 shadow 记录,达到最小样本量和准确率后才能影响真实路由。\n- advisor 执行面必须满足 `OriginTier in {first, local}`;否则拒绝创建或自动降级为 disabled。\n- advisor 不能覆盖 `ManualRoutingIntent`、X-Channel、session pin、显式 modelMapping。\n\n#### 4.7.3 Fable/高价模型用量优化\n\n目标不是让 advisor 识别“只有 Fable 能做”,而是让系统可靠识别“不需要 Fable 的已知低风险任务”:\n\n```text\n固定高优先级保留给 Fable / premium:\n - 全局架构设计、复杂规划、跨模块一致性推理\n - 长上下文综合、需求不明确但风险高的任务\n - 失败代价高的代码修改、迁移、发布决策\n\n可由本地/便宜模型优先尝试:\n - 日志分类、标题生成、格式转换、短摘要\n - 渠道健康证据归类、错误原因初筛\n - 明确模板化的配置解释、成本估算、简单路由标签\n\n不确定:\n - 直接升级到 high/premium,而不是让本地模型猜\n```\n\n这样 advisor 优化的是”明显不用强模型”的部分,而不是把强模型任务拆碎后试图合并。\n\n### 4.8 Endpoint 级亲和保持机制\n\n**背景**:现有 `TraceAffinityManager` 实现了渠道级亲和(同一 trace 固定到同一 channel index)。Autopilot 引入 endpoint 级后,需要在 channel 内进一步稳定 `(baseURL, key)` 对的选择,避免每次请求都随机轮转 key。\n\n#### 4.8.1 亲和粒度层级\n\n```text\n层级 1:Channel 亲和(现有,已实现)\n key: traceAffinityKey(kind, userID, contextBucket)\n value: channelIndex\n 由 session.TraceAffinityManager 管理,含超时续期\n\n层级 2:Endpoint 亲和(新增,Phase 2)\n key: endpointAffinityKey(kind, userID, contextBucket)\n value: metricsKey(= GenerateMetricsIdentityKey(baseURL, apiKey, serviceType))\n 在 TryUpstreamWithAllKeys 成功后由 autopilot 写入,失败后清除\n\n两层独立管理:channel 亲和失效不自动清 endpoint 亲和;endpoint 亲和失效只在\n同 channel 内重新选 key,不影响 channel 亲和。\n```\n\n#### 4.8.2 亲和选取与保持规则\n\n```text\n请求进入 TryUpstreamWithAllKeys:\n 1. 查 endpoint 亲和 → 得到 preferred metricsKey\n 2. 检查 preferred endpoint 的当前状态:\n ├─ 健康且未被 policy 过滤 → 提升到候选列表首位(不跳过其他候选)\n ├─ 处于 cooldown/熔断/拉黑 → 忽略亲和,走 policy 正常排序\n └─ key 被禁用 / 画像 HealthState=dead → 清除亲和记录,走正常排序\n\n请求成功(任意 endpoint):\n → SetEndpointAffinity(kind, userID, contextBucket, metricsKey)\n → UpdateTraceAffinity(kind, userID)(channel 层续期)\n\n请求失败(该 endpoint):\n → 不清除亲和,仍允许下次优先尝试(避免偶发失败导致频繁漂移)\n → 连续失败 ≥ 3 次 → ClearEndpointAffinity,下次走正常排序\n```\n\n#### 4.8.3 亲和失效条件\n\n| 条件 | 行为 |\n|------|------|\n| key 被显式禁用 | 立即清除亲和,failover 到同 channel 其他 key |\n| endpoint 进入 cooldown(429) | 忽略亲和,不清除;cooldown 结束后恢复优先 |\n| 熔断器 open | 忽略亲和,不清除;半开后恢复优先 |\n| 连续失败 ≥ 3 次(L1 被动) | 清除亲和,重新选 endpoint |\n| channel 亲和漂移(failover 换 channel) | 清除旧 channel 的 endpoint 亲和 |\n| 亲和 TTL 超时(默认 30min) | 自动清除,下次重新建立 |\n| `session_pin` ManualRoutingIntent | 覆盖自动亲和,直到 intent 到期 |\n\n## 5. 智能调度策略\n\n### 5.1 任务分类 (TaskClassifier)\n\n请求进入时自动分类,决定调度策略:\n\n```go\n// backend-go/internal/autopilot/task_classifier.go\n\nfunc ClassifyRequest(profile *RequestProfile) TaskClass {\n // 1. 原生生图端点优先判定;不做 chat → images 协议转换\n if profile.ChannelKind == \"images\" || profile.ImageGenNeed {\n return TaskClassImageGen\n }\n\n // 2. 原生 embedding / vectors 端点优先判定\n if profile.ChannelKind == \"vectors\" || profile.EmbeddingNeed {\n return TaskClassEmbedding\n }\n\n // 3. 识图理解任务\n if profile.HasImage && profile.VisionNeed {\n return TaskClassVision\n }\n\n // 4. 长上下文任务\n if profile.ContextNeed > 200_000 {\n return TaskClassLongContext\n }\n\n // 5. 明确的低风险轻任务\n if isLightweightRequest(profile) {\n return TaskClassLightweight\n }\n\n // 6. 主代理/监工\n if profile.AgentRole == \"main\" || profile.AgentRole == \"\" {\n // 主代理默认走 Supervisor 策略\n return TaskClassSupervisor\n }\n\n // 7. 子代理\n if profile.AgentRole == \"subagent\" {\n // 子代理默认走 Worker 策略\n return TaskClassWorker\n }\n\n return TaskClassWorker // 兜底\n}\n```\n\n`isLightweightRequest` 必须是确定性规则,不能让 advisor 自己发明分类:\n- `Operation=count_tokens`、标题/分类/格式转换等已知模板。\n- 上下文 < 10K、无图片、无工具调用、无 reasoning 需求。\n- 模型名包含 `haiku` / `mini` / `flash` 只能作为弱信号,不能单独决定 lightweight。\n\n### 5.2 调度策略矩阵\n\n每个 TaskClass 对应一组优先级规则:\n\n#### Supervisor(主代理/监工)\n\n```text\n优先级 1:qualityTier=high|premium + stabilityTier=stable + 长上下文\n优先级 2:qualityTier=high + stabilityTier=normal\n优先级 3:qualityTier=normal + stabilityTier=stable\n同档排序:优先 stability,再按 estimatedRequestCost 从低到高\n降级 :qualityTier=high + stabilityTier=degraded(仅当无稳定高智商渠道时)\n禁止 :stabilityTier=unstable, costTier=free, qualityTier=low\n```\n\n#### Worker(子代理)\n\n```text\n硬约束 :满足 CapabilityFloor + MinQualityTier,不允许质量降级省钱\n优先级 1:estimatedRequestCost 最低 + stabilityTier>=normal\n优先级 2:costTier=free|cheap + qualityTier=normal|high(临时池/白嫖池)\n优先级 3:costTier=cheap + speedTier=fast\n优先级 4:speedTier=fast + qualityTier=low|normal\n优先级 5:qualityTier=normal + stabilityTier=stable(常规池)\n默认跳过:costTier=expensive, qualityTier=premium\n```\n\n#### Lightweight(轻任务)\n\n```text\n优先级 1:estimatedRequestCost 最低 + speedTier=fast\n优先级 2:speedTier=fast + costTier=free|cheap\n优先级 3:costTier=free\n优先级 4:speedTier=fast\n禁止 :qualityTier=premium, 视觉池, 长上下文池\n```\n\n#### Vision(识图任务)\n\n```text\n硬过滤 :SupportsVision=true 且 SupportsToolCalls=true(如需要)\n优先级 1:qualityTier=high|premium + vision\n优先级 2:qualityTier=normal + vision\n同档排序:estimatedRequestCost 从低到高,但不牺牲 vision/tool 硬约束\n降级 :当所有 vision 渠道不可用时,尝试 visionFallbackModel\n禁止 :SupportsVision=false 的渠道\n```\n\n#### ImageGeneration(原生生图任务)\n\n```text\n硬过滤 :channelKind=images,支持请求的 operation(generation/edit/variation)和 model\n优先级 1:healthState=healthy + stabilityTier>=normal + estimatedRequestCost 最低\n优先级 2:speedTier=fast + costTier=cheap|normal\n优先级 3:qualityTier=normal|high + 最近 15m 成功率高\n参数处理:透传 images API 原生参数;只做必要校验和脱敏日志\n禁止 :把 chat/messages/responses 文本请求自动转换为 images 请求\n```\n\nMVP 不做“聊天模型画图”协议转换。用户如果要用某个上游的生图服务,应把它作为 `images` 类型渠道添加,Autopilot 负责发现、健康画像、限速和 endpoint 排序。\n\n#### Embedding(原生向量任务)\n\n```text\n硬过滤 :channelKind=vectors,支持请求模型和 handler 要求的 embedding 维度/格式\n优先级 1:healthState=healthy + estimatedRequestCost 最低\n优先级 2:stabilityTier=stable + p95LatencyMs 低\n优先级 3:同模型/同维度历史成功率高\n参数处理:透传 embeddings API 原生参数;不改写 input,不跨模型猜测维度\n禁止 :把 chat/messages/responses 文本请求自动转换为 embeddings 请求\n```\n\n#### LongContext(长上下文)\n\n```text\n硬过滤 :ContextWindowTokens >= 请求需要的最小窗口\n优先级 1:qualityTier=high|premium + longContext + stable\n优先级 2:qualityTier=normal + longContext\n同档排序:estimatedRequestCost 从低到高,但不牺牲上下文窗口\n禁止 :ContextWindowTokens < 需求 或 SupportsLongCtx=false\n```\n\n### 5.3 CandidateFilter 实现\n\n```go\nfunc (r *SmartRouter) filterByTaskStrategy(\n channels []scheduler.ChannelInfo,\n profiles map[int]*ChannelProfile,\n strategy taskStrategy,\n) []scheduler.ChannelInfo {\n\n // 1. 硬过滤:排除不满足硬约束的渠道\n filtered := hardFilter(channels, profiles, strategy)\n\n // 2. 标签评分:每个渠道按策略规则打分\n scored := scoreChannels(filtered, profiles, strategy)\n\n // 3. 按分数降序排列\n sort.Slice(scored, func(i, j int) bool {\n return scored[i].Score > scored[j].Score\n })\n\n // 4. 返回重排后的 ChannelInfo 列表\n return scored.ToChannelInfoList()\n}\n```\n\n**评分公式**:\n\n```text\nScore = w_quality * qualityScore\n + w_stability * stabilityScore\n + w_speed * speedScore\n + w_cost * costScore\n + w_savings * savingsScore\n + w_tier_match * tierMatchBonus\n + w_family * familyPreferenceScore // 新增:模型派系偏好软约束\n + w_provider_quality * providerQualityScore // 新增:同模型上游供应商质量差异\n + w_domain * domainStrengthScore // 新增:任务域优势(审美/代码审核等强项差异)\n - penalty\n\n其中:\n qualityScore: low=1, normal=2, high=3, premium=4\n stabilityScore: unstable=0, normal=1, stable=2\n speedScore: slow=0, normal=1, fast=2\n costScore: expensive=0, normal=1, cheap=2, free=3\n savingsScore: normalizeCheapest(estimatedRequestCost),越便宜越高,仅在满足硬约束后参与\n familyPreferenceScore: 见下方\"模型派系偏好\"章节\n providerQualityScore: 该endpoint对该模型的实现质量,0.0-1.0(1.0=最优实现)\n domainStrengthScore: 模型在当前任务域的优势分,0.0-1.0(0.5=中性),见 §5.7\n\n tierMatchBonus: 渠道画像标签匹配策略优先标签时 +10\n penalty: healthState=degraded 时 -5, limited 时 -20;assist 下 dead/misconfigured 额外 -100 并保留在队尾\n\n 权重根据 TaskClass 不同(w_family 统一为 0.2,w_provider_quality 统一为 1.0):\n Supervisor: w_quality=3, w_stability=2, w_speed=1, w_cost=0, w_savings=0.5, w_family=0.2, w_provider_quality=1.0\n Worker: w_quality=1, w_stability=1, w_speed=2, w_cost=2, w_savings=3, w_family=0.2, w_provider_quality=0.8\n Lightweight: w_quality=0, w_stability=1, w_speed=3, w_cost=2, w_savings=3, w_family=0.1, w_provider_quality=0.5\n Vision: w_quality=2, w_stability=2, w_speed=1, w_cost=1, w_savings=1, w_family=0.2, w_provider_quality=1.0\n ImageGeneration: w_quality=1, w_stability=2, w_speed=1, w_cost=2, w_savings=2, w_family=0.1, w_provider_quality=0.3\n Embedding: w_quality=0, w_stability=2, w_speed=2, w_cost=3, w_savings=3, w_family=0.0, w_provider_quality=0.0\n LongContext: w_quality=2, w_stability=2, w_speed=1, w_cost=0, w_savings=1, w_family=0.2, w_provider_quality=1.0\n```\n\n**w_family 权重约束验证**:`w_stability × (stable=2 − unstable=0) = 2.0`,而 `w_family × max(familyPreferenceScore) ≤ 0.2 × 3 = 0.6`。因此 stable 的非偏好派系(+2.0)始终胜过 unstable 的偏好派系(+0.6),派系偏好只在同等稳定性下才决定顺序。\n\n**w_provider_quality 权重约束验证**:`w_provider_quality × (1.0 − 0.0) = 1.0`,而 `w_stability × 2.0 = 2.0`。因此 stable 的低供应商质量实现仍优于 unstable 的高质量实现,供应商质量只在同等稳定性内打破平局。\n\n**w_domain 权重约束验证**:`w_domain` 统一取 0.5(embedding/image_generation 取 0),`w_domain × (1.0 − 0.0) = 0.5 < w_stability × 2.0`。任务域优势只在同等稳定性/健康度内影响排序,不会让\"审美好但不稳定\"的渠道胜出。TaskDomain 无法细分时进入 `general`;有规范 Knowledge 基准则使用其软评分,缺少直接证据时仍取 0.5 中性值。\n\n**ProviderQualityScore 推导规则**:\n\n```text\nL3 深度探测(默认来源):\n 探测 prompt:同一测试用例(固定长度、固定语言、固定复杂度)\n 评分维度:\n 响应完整性 40%:输出 token 数 vs 预期(完整回答 vs 截断)\n 语义一致性 30%:与基线响应的语义相似度(cosine similarity 或 LLM-as-judge)\n 格式正确性 15%:tool_use block 是否合法、reasoning block 是否存在\n 首token延迟 15%:响应开始速度\n providerQualityScore = weighted_average(各维度得分)\n\n用户反馈(高优先级 override):\n 用户在 UI 标记某个 endpoint 的某个模型\"质量好\"或\"质量差\"\n providerQualityScore = clamp(userScore, 0.0, 1.0)\n ProviderQualitySource = \"user_feedback\",confidence = 0.9\n\n启发式推导(低置信度兜底):\n 基于 OriginTier + 历史成功响应的平均长度/完整度\n official_api 默认 0.95,relay 默认 0.7,community 默认 0.5,unknown 默认 0.6\n ProviderQualitySource = \"inferred\",confidence = 0.4\n\n约束:\n - 置信度 < 0.5 时 providerQualityScore 不参与评分(视为0分)\n - 用户反馈覆盖探测结果,但用户反馈 TTL 为 30 天,过期后回退探测\n - 不同模型族的 ProviderQualityScore 独立评估(opus 在kiro的质量不等于sonnet在kiro的质量)\n```\n\n**手动 L3 第一版状态(2026-07-15)**:已实现 `pq-v1-20260715` 固定 JSON canary,覆盖\nMessages、Chat Completions、Responses 与 Gemini 上游协议。调用方只能指定 `endpointUid`、\n`modelId` 和 1~3 次重复数,不能注入 prompt。评分仍按 40% 完整性、30% 语义正确性、\n15% 严格 JSON 格式、15% 延迟执行;第一版使用非流式完整响应延迟作为首 token 延迟代理,\n后续引入流式采样时再替换该维度。单次有效样本 confidence=0.6,最多三次提升到 0.8;\n失败样本按 0 分计入均值,全部失败不写画像。\n\n安全边界:仅管理 API 手动触发,不启动自动 worker;当前进程按 UTC 日设置 12 次硬预算,\n请求前原子预留,单次 90 秒超时、响应体上限 64 KiB。同一 endpoint×model 不并发执行;\n单次请求最多允许 2048 个输出 token,以容纳推理模型的隐藏思考并限制最坏额度消耗。\n对声明可控推理的模型固定采用最低可用 effort;Claude 兼容端点若只暴露\n`reasoning_content` 而未声明 thinking 模式,则与 capability-test 一致显式关闭 thinking。\n原始模型输出和明文 Key 既不落盘也不返回,只保存分数、canary 版本、置信度和脱敏判定。\n`user_feedback` 仍高于 `probe`,自动发现重建 ModelProfile 时必须保留既有质量证据。\n跨进程共享预算与 LLM-as-judge/语义向量评分仍属于后续阶段。\n\n### 5.5 模型派系偏好 (Model Family Preference)\n\n用户在不同派系的模型(Claude / GPT / Gemini / DeepSeek 等)之间有主观偏好,且偏好本身与渠道质量无关。本章定义派系偏好的数据模型、推导逻辑和排序语义。\n\n#### 5.5.1 ModelFamily 枚举与推导\n\n```go\n// backend-go/internal/autopilot/model_family.go\n\ntype ModelFamily string\n\nconst (\n // ── 国际主流 ──\n ModelFamilyClaude ModelFamily = \"claude\" // claude-*,Anthropic\n ModelFamilyOpenAI ModelFamily = \"openai\" // gpt-*, o*, codex-*,OpenAI / Amazon Bedrock\n ModelFamilyGemini ModelFamily = \"gemini\" // gemini-*,Google\n ModelFamilyMistral ModelFamily = \"mistral\" // mistral-*, mixtral-*,Mistral AI\n\n // ── 国产主流 ──\n ModelFamilyDeepSeek ModelFamily = \"deepseek\" // DeepSeek V3/V4,DeepSeek\n ModelFamilyQwen ModelFamily = \"qwen\" // qwen3-*,通义千问,DashScope\n ModelFamilyGLM ModelFamily = \"glm\" // glm-5-*,智谱 AI\n ModelFamilyKimi ModelFamily = \"kimi\" // kimi-k2-*,月之暗面 Moonshot\n ModelFamilyMiMo ModelFamily = \"mimo\" // mimo-v2-*,小米\n ModelFamilyERNIE ModelFamily = \"ernie\" // ernie-4.5,百度\n ModelFamilyDoubao ModelFamily = \"doubao\" // doubao-seed-*,字节豆包 Volcengine\n ModelFamilyMiniMax ModelFamily = \"minimax\" // minimax-m*,MiniMax\n ModelFamilyYi ModelFamily = \"yi\" // yi-*,零一万物 01.ai\n ModelFamilyBaichuan ModelFamily = \"baichuan\" // baichuan-m*,百川智能\n ModelFamilyStep ModelFamily = \"step\" // step-3.*,阶跃星辰 StepFun\n ModelFamilySenseNova ModelFamily = \"sensenova\" // sensenova-6.*,商汤 SenseTime\n ModelFamilyAgnes ModelFamily = \"agnes\" // agnes-2.*,Xiaomi 独立系列\n ModelFamilyLongCat ModelFamily = \"longcat\" // longcat-2.*,京东\n\n // ── 特殊 ──\n ModelFamilyLocal ModelFamily = \"local\" // ollama/lmstudio/llama-server 本地运行时\n ModelFamilyUnknown ModelFamily = \"unknown\"\n)\n\n// InferModelFamily 从模型注册表 Provider 字段推导派系,注册表显式标注优先\n// 同时按生成的 ModelProfile.ModelFamily 写入画像\nfunc InferModelFamily(modelID string, registryCapabilities map[string]UpstreamModelCapability) ModelFamily {\n // 优先从 BuiltinUpstreamModelCapabilities 的 Provider 字段精确映射\n // Provider→ModelFamily 映射表(从 generated_model_registry.go 提取):\n // anthropic → claude\n // openai / amazon-bedrock → openai\n // dashscope → qwen\n // volcengine → doubao\n // xiaomi → mimo 或 agnes(按前缀细分)\n // baidu → ernie\n // 01-ai → yi\n // moonshot → kimi\n // zai → glm\n // 其余 Provider 直接映射同名 family\n // 回退到模型 ID 前缀匹配(兜底未知模型)\n}\n```\n\n**⚠️ 派系偏好不覆盖版本差异**:同一个 ModelFamily 内不同模型版本的 QualityTier 和能力可能完全不同。\n\n```text\n示例:familyPreferenceOrder = [\"claude\", \"openai\"]\n\nClaude Opus 4.8 (claude, premium) vs Claude Haiku 4.5 (claude, normal)\n → 同一派系,但QualityTier 不同 → QualityTier 决定优先级,与派系偏好无关\n\nClaude Opus 4.8 (claude, premium) vs GPT-5.4 (openai, premium)\n → 不同派系,同 QualityTier → familyPreferenceScore 决定,claude 得分更高\n\nClaude Haiku 4.5 (claude, normal) vs GPT-5.4 (openai, premium)\n → 不同派系,不同 QualityTier → qualityScore 差距 > familyPreferenceScore → GPT-5.4 胜出\n```\n\n因此 `w_family × max_score ≤ 0.6`,而 `w_quality × (premium − normal) = 3.0`,版本间的质量差异总是压倒派系偏好。派系偏好只在同版本档次(同QualityTier)内打破平局。\n\n#### 5.5.2 familyPreferenceScore 计算\n\n```go\n// 用户配置的派系偏好顺序,例如 [\"claude\", \"openai\", \"gemini\"]\n// familyPreferenceScore:越靠前分越高,不在列表中得 0 分\nfunc CalcFamilyPreferenceScore(family ModelFamily, prefs []ModelFamily) float64 {\n n := len(prefs)\n for i, f := range prefs {\n if f == family {\n return float64(n - i) // 第1位得n分,第2位得n-1分,…最后1位得1分\n }\n }\n return 0\n}\n```\n\n**示例**:偏好顺序 `[\"claude\", \"openai\", \"deepseek\"]`(n=3)\n\n| 派系 | familyPreferenceScore | × w_family=0.2 | 对总分影响 |\n|------|----------------------|----------------|-----------|\n| claude | 3 | +0.6 | 同等条件下最优先 |\n| openai | 2 | +0.4 | 次优先 |\n| deepseek | 1 | +0.2 | 再次 |\n| qwen / kimi / glm / doubao / mimo / er 等其余 13 个派系 | 0 | +0.0 | 无偏好加成 |\n\n当两个 stable 的 premium 渠道并列,claude 比 openai 多 +0.2,决定顺序;但 stable openai 比 unstable claude 多 2.0 - 0.6 = +1.4,稳定性压倒派系偏好。\n\n#### 5.5.3 按 TaskClass 独立配置\n\n不同场景对派系的感知不同。推荐全局配置 + 任务级 override:\n\n```json\n\"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"gemini\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"claude\", \"gemini\", \"deepseek\"],\n \"lightweight\": [\"openai\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"]\n }\n}\n```\n\n规则:\n- `perTaskClass` 中存在的 TaskClass 使用其专属顺序;否则回退 `globalOrder`。\n- `enabled=false` 时 `familyPreferenceScore` 恒为 0,评分退化为原有公式。\n- `weight` 覆盖全局 `w_family`;可在管理面板调整(建议范围 0.1 ~ 0.5)。\n- Embedding / ImageGeneration 默认 `w_family=0.0`(上游原生端点选择优先技术兼容性,派系偏好意义不大)。\n\n### 5.6 用户价格偏向 (Cost Preference)\n\n同一模型在不同供应商的价格差异显著(官方 opus vs AWS Bedrock vs kiro 中转),且质量与价格通常正相关。系统不应替用户决定\"质量优先还是省钱优先\"——这是用户的权利。\n\n#### 5.6.1 三档预设\n\n```json\n\"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n }\n}\n```\n\n| 模式 | savingsMultiplier | providerQualityMultiplier | 语义 |\n|------|-------------------|---------------------------|------|\n| `quality_first` | 0.3 | 1.5 | 质量优先:官方 opus 胜过便宜的 kiro,即使贵 3 倍 |\n| `balanced` | 1.0 | 1.0 | 默认:按各 TaskClass 原始权重 |\n| `cost_first` | 2.0 | 0.5 | 省钱优先:kiro 的 opus 在满足硬约束后优先胜出 |\n\n乘数是预设内建的常量,不对外暴露;非法或未知 mode 归一化回退 `balanced`。\n\n生效方式是权重乘数,不改公式结构:\n\n```text\neffective_w_savings = w_savings × savingsMultiplier\neffective_w_provider_quality = w_provider_quality × providerQualityMultiplier\n```\n\n#### 5.6.2 不可突破的边界\n\n价格偏向是软偏好,以下边界任何模式都不能突破:\n\n```text\n1. CapabilityFloor / MinQualityTier:cost_first 也不能把 supervisor 降到不满足质量下界的模型\n2. 稳定性优先:cost_first 下 stable 贵渠道仍胜过 unstable 便宜渠道\n (w_stability × 2.0 > effective_w_savings × normalizeCheapest 的分差上限需在实现时校验钳制)\n3. vision/tool/reasoning/上下文硬约束:不因省钱跳过\n4. 成本置信度 < requireCostConfidence 时,savingsMultiplier 不生效(无价格数据不能假装省钱)\n5. quality_first 不豁免限速/熔断:官方渠道被限流时仍然 failover 到次选\n```\n\n#### 5.6.3 典型场景\n\n```text\n场景:用户有官方 Anthropic($15/$75,providerQuality=0.95)、\n AWS Bedrock($15/$75,providerQuality=0.85)、\n kiro 中转(0.4x 折扣,providerQuality=0.70),三者都 stable\n\nquality_first(supervisor 默认):\n 官方 > Bedrock > kiro —— 质量差距被放大 1.5 倍,价格几乎不影响\n\nbalanced:\n 官方 > kiro ≈ Bedrock —— kiro 靠 0.4x 价格追平 Bedrock 的质量优势\n\ncost_first(worker 默认):\n kiro > 官方 ≈ Bedrock —— 省钱权重放大 2 倍,质量差距缩小一半;\n 但若 kiro 变 unstable,立即让位给官方/Bedrock\n```\n\nUI 要求:驾驶舱和渠道中心显示当前生效模式;当 quality_first 用户的请求因限流 failover 到低质量供应商时,显示「已临时降级到 kiro(原因:官方限流中)」。\n\n文本类请求的 `estimatedRequestCost` 使用请求级 token 估算:\n\n```text\nestimatedRequestCost =\n estimatedInputTokens * effectiveInputCostPerMTok / 1_000_000 +\n estimatedOutputTokens * effectiveOutputCostPerMTok / 1_000_000\n```\n\n当输出 token 不可预估时:\n- supervisor/long_context 使用模型推荐输出上限的保守比例;\n- worker/lightweight 使用最近同类请求的 p50 输出 token;\n- 如果仍无数据,只使用输入成本做 tie-breaker,不做强排序。\n\nImages / vectors 使用各自原生计价,不强行换算成 chat token:\n\n```text\nimage_generation:\n estimatedRequestCost =\n imageUnitPrice(model, size, quality, operation) * n * effectiveCostMultiplier\n\nembedding:\n estimatedRequestCost =\n estimatedInputTokens * effectiveEmbeddingCostPerMTok / 1_000_000\n```\n\n如果上游没有公开 image/vector 价格:\n- 用户手动配置的 `GroupMultipliers` / `RechargeMultiplier` 仍生效。\n- 成本置信度低于阈值时只做展示,不参与强排序。\n- vectors 可用最近同模型/同维度请求均价作为 shadow 估算;images 不用 chat token 价格替代生图价格。\n\n### 5.7 任务域优势矩阵 (Task-Domain Strength)\n\n模型质量不是标量:不同模型的强项任务不同。典型例子:opus / fable / gemini / glm 的审美(前端 UI、视觉设计)明显好于 gpt,但 gpt 的代码审核能力堪比 fable。单一 QualityTier 无法表达这种\"按任务域各有胜负\"的差异,本章引入 TaskDomain 维度。\n\n#### 5.7.1 TaskDomain 与 TaskClass 的区别\n\n```text\nTaskClass 回答\"谁在干活/资源档位\":supervisor / worker / lightweight / vision / …\nTaskDomain 回答\"干的是什么活/内容领域\":审美、代码审核、算法、写作、翻译…\n\n两者正交:一个 worker(TaskClass)可能在写前端 UI(aesthetics_ui 域),\n也可能在做代码审核(code_review 域),最优模型完全不同。\n```\n\n```go\ntype TaskDomain string\n\nconst (\n TaskDomainAestheticsUI TaskDomain = \"aesthetics_ui\" // 前端 UI/视觉设计/审美\n TaskDomainCodeReview TaskDomain = \"code_review\" // 代码审核/找 bug\n TaskDomainCoding TaskDomain = \"coding\" // 通用编码实现\n TaskDomainReasoning TaskDomain = \"reasoning\" // 算法/数学/复杂推理\n TaskDomainWriting TaskDomain = \"writing\" // 文案/长文写作\n TaskDomainTranslation TaskDomain = \"translation\" // 翻译\n TaskDomainAgentic TaskDomain = \"agentic\" // 多步工具调用/agent 编排\n TaskDomainGeneral TaskDomain = \"general\" // 无法细分的通用任务;缺少基准证据时中性\n)\n```\n\n#### 5.7.2 域推导(确定性规则)\n\n与 P0.3 确定性 TaskClassifier 相同约束:域推导必须是确定性规则,不引入 LLM 判断,同一请求永远推导出同一 TaskDomain。\n\n```text\n信号来源(按优先级):\n1. 请求头显式声明:X-Task-Domain(客户端/上层 agent 直接指定,最高优先级)\n2. system prompt 关键词匹配:固定关键词表(\"code review\"/\"审查代码\"→code_review;\n \"UI\"/\"设计\"/\"样式\"/\"Tailwind\"→aesthetics_ui;\"翻译\"→translation…)\n3. 工具集特征:请求携带的 tools 列表特征(如仅有 read/grep 类只读工具 + diff 上下文→code_review)\n4. 消息内容特征:首条 user 消息的文件扩展名分布(.vue/.css→aesthetics_ui)\n\n任何信号都不足时 → general;有规范 Knowledge 基准时使用其软评分,缺少直接证据时取 0.5 中性。\n误判代价有界:w_domain=0.5 上限远低于稳定性/质量权重,误判最多影响同档候选的相对顺序。\n```\n\n#### 5.7.3 种子矩阵与覆盖\n\n内置 ModelFamily 级种子矩阵(出厂默认,随版本更新),ModelProfile 级 `TaskDomainStrengths` 可覆盖单个模型:\n\n```text\n aesthetics_ui code_review coding reasoning writing\nclaude(fable) 0.90 0.90 0.85 0.90 0.85\nclaude(opus) 0.90 0.85 0.85 0.85 0.85\nopenai(gpt) 0.60 0.90 0.80 0.85 0.70\ngemini 0.85 0.75 0.75 0.80 0.75\nglm 0.80 0.70 0.75 0.70 0.75\ndeepseek 0.55 0.75 0.80 0.85 0.65\n(未列出的族/域一律 0.5 中性;数值为种子先验,允许用户在配置中整体替换)\n```\n\n```text\ndomainStrengthScore = ModelProfile.TaskDomainStrengths[domain]\n ?? canonicalBenchmark[model][domain] * providerQualityFactor\n ?? seedMatrix[family][domain]\n ?? 0.5\n\n来源优先级:用户配置覆盖 > ModelProfile 探测/反馈值 > 规范模型基准 > 种子矩阵 > 0.5\n用户反馈通路与 ProviderQualityScore 相同:UI 标记\"这个模型做 X 类任务很好/很差\",\n写入 ModelProfile.TaskDomainStrengths,TTL 90 天(域优势比供应商质量稳定,衰减更慢)。\n```\n\n同版本迭代说明:同族不同版本的域优势也会漂移(如 gpt 新版审美追上),因此种子矩阵按 `family + 主版本` 建键(如 `openai/gpt-5.x`),注册表更新时同步刷新种子值。\n\n#### 5.7.4 规范模型能力基准\n\n`shared/model-registry/ccx_model_registry.json` 的 `benchmarkProfiles` 独立保存规范模型能力上界,不与 `upstreamCapabilities` 混合。后者仍是 context、vision、tool calls 等硬能力的事实源;benchmark 只参与 Autopilot 软评分和 trace 解释,绝不改变 `supportedModels` 或 CapabilityFloor。\n\n```text\nEffectiveCapability[domain]\n = CanonicalCeiling[model, domain]\n × ProviderQualityFactor[endpoint]\n\nProviderQualityFactor = 1 - confidence × (1 - providerQualityScore)\n无渠道质量证据或 confidence<0.5 时 factor=1;可信证据只能从规范模型上界向下折算。\n```\n\n基准数据必须保留领域向量、来源、核验日期、lane、共享结果数和可比类别覆盖率,不能只保存站点 Overall。当前 BenchLM 接入为 `provisional`:Claude Opus 4.8、GPT-5.6 Terra、GPT-5.6 Sol 各有 30 个共享结果,8 类中 5 类可比,核验日期为 2026-07-13。\n\n领域映射:Knowledge → `general`,Math → `reasoning`,Coding → `coding` / `code_review`,Agentic → `agentic`,Multimodal → `aesthetics_ui`(仅弱代理,解释置信度减半)。Writing / Translation 没有直接类别证据时继续回退家族种子或 0.5。BenchLM 的 cohort 归一化、缺失项插补和外部校准决定了 Overall 不是绝对能力值,因此路由只消费可映射的原始类别分。\n\n### 5.8 思考等级差异 (Reasoning Effort Levels)\n\n同一个模型在不同思考能力等级(thinking budget / reasoning effort)下的有效智商差异显著,代价是少量额外的 think 输出 token 开销。调度不应只在\"选哪个模型\"上做文章——\"同一个模型开多大思考\"是一个更便宜的质量杠杆:把 medium 提到 high 的成本增量通常远小于换到更贵一档的模型。\n\n#### 5.8.1 EffortLevel 档位与派系映射\n\n```go\ntype EffortLevel string\n\nconst (\n EffortOff EffortLevel = \"off\" // 不开思考\n EffortMinimal EffortLevel = \"minimal\"\n EffortLow EffortLevel = \"low\"\n EffortMedium EffortLevel = \"medium\"\n EffortHigh EffortLevel = \"high\"\n EffortMax EffortLevel = \"max\"\n)\n```\n\n统一档位到各派系原生参数的映射(发请求时翻译,画像/评分只用统一档位):\n\n```text\nclaude: thinking.budget_tokens(off=不带 thinking 块;minimal≈1k/low≈4k/medium≈10k/high≈32k/max=模型上限)\nopenai: reasoning_effort = minimal|low|medium|high(无 max 的模型 high 封顶)\ngemini: thinkingConfig.thinkingBudget(同 claude 按 token 预算映射)\ndeepseek/glm/qwen…: 各自 thinking 开关/预算参数,映射表放模型注册表,缺省 SupportsEffortControl=false\n```\n\n#### 5.8.2 候选变体展开与评分影响\n\n对 `SupportsEffortControl=true` 的候选,SmartRouter 按 TaskClass 策略展开思考档位变体 `(endpoint, model, effort)`,同一模型的不同档位作为不同候选参与同一套评分:\n\n```text\neffort 不是新的权重项,它修改既有两个输入:\n qualityScore(variant) = baseQualityScore + effortQualityBonus(effort)\n (off=0 / minimal=+0.2 / low=+0.4 / medium=+0.6 / high=+0.9 / max=+1.0,\n 上限钳制在 premium=4,避免\"低档模型开满思考\"虚标超过高档模型)\n estimatedRequestCost(variant) += estimatedThinkTokens(effort) × effectiveOutputCostPerMTok / 1e6\n (think token 按档位经验均值估算,来源于历史同档位请求的 p50)\n\n净效果:quality_first/supervisor 自然偏向高思考档;cost_first/lightweight 自然偏向 off/minimal——\n不需要独立的 effort 策略引擎,成本偏好(§5.6)的乘数直接作用于 think 开销。\n```\n\n每 TaskClass 的展开范围(避免候选爆炸,每模型最多展开 2 个档位):\n\n```text\nsupervisor: [high, max] worker: [medium]\nlightweight: [off, minimal] vision: [medium]\nlong_context: [medium, high] image_generation/embedding: 不展开\n```\n\n#### 5.8.3 不可突破的边界\n\n```text\n1. 客户端显式 thinking 配置永远优先:请求体已带 thinking/reasoning_effort 参数时,\n CCX 忠实转发,不展开变体、不改档(代理诚实性原则)\n2. 只对 autoManaged 且渠道允许注入思考参数的 endpoint 展开;\n 现有渠道级思考配置(如 PassbackReasoningContent)语义不变\n3. 升档受成本边界约束:estimatedRequestCost 含 think 开销后仍需通过 §5.6 的排序,\n 不允许\"为了质量分虚增思考预算\"绕过 cost_first\n4. effortQualityBonus 不改变 CapabilityFloor 判定:质量下界按 baseQualityTier 评估,\n \"低档模型 + max 思考\"不能顶替下界要求的高档模型\n5. 上游不支持该档位时就近降档(max→high),但绝不低于客户端等效请求的档位\n```\n\n#### 5.8.4 联合决策的现状实现(model + effort 原子化)\n\n> 状态说明(2026-07-26):本节描述的联合决策链路已实现并接入真实请求路径(关联计划 [2026-07-26-autopilot-model-effort-joint-routing.md](../superpowers/plans/2026-07-26-autopilot-model-effort-joint-routing.md) 的 Task 1~7)。**渠道级手工 `modelMapping` / `reasoningMapping` 表目前仍然存在且仍然生效**,退役是该计划的 Task 8,尚未执行;手工表当前的优先级关系见下文。\n\n**决策对象。** 自动路由的返回值不再是裸模型名,而是 `autopilot.ResolvedRouteTarget`(`backend-go/internal/autopilot/route_target.go`):\n\n```go\ntype ResolvedRouteTarget struct {\n Model string // 目标模型 ID\n Effort EffortLevel // 目标思考档位;空串表示不改写\n EffortDecided bool // true 表示 effort 由 Autopilot 决定;false 为 passthrough\n Reason string // 决策原因,用于 Trace 与日志\n}\n```\n\n`EffortDecided=false` 且 `Effort` 为空是合法的 fail-open 结果:模型可能被改写,但思考参数保持原样。\n\n**决策发生的位置。** `autopilot/endpoint_policy.go` 的 `resolveMappedModel()` 是统一入口,内部分两路:`resolveManualMapping()` 处理渠道级显式 `ModelMapping`(仍是当前优先级更高的一路,返回的 `ResolvedRouteTarget.EffortDecided` 恒为 `false`,即手工映射只改模型不改档位);`resolveAutoModel()` 调用 `ModelResolver` 做自动模型解析,并在 `model_resolver.go` 的 `rankEligibleModels()` 中按 `SupportedEffortLevels` × `PerTaskClass` 展开 `(model, effort)` 候选、应用 `EffortQualityBonus` 与 anti-effort-inflation 反膨胀 tiebreak(同质量档优先选低 effort)、并按 `EffortFloor` 过滤掉低于任务下界的候选。产出的 `ResolvedRouteTarget` 经 `buildResolvedTargetLookup()` 挂到 `EndpointAttemptPolicy.ResolvedTargetByEndpointUID`,与旧的 `ResolvedModelByEndpointUID`(仅模型,向后兼容)并列。\n\n**决策如何到达上游请求。** `handlers/common/upstream_failover.go` 的 `TryUpstreamWithAllKeys` 在为每个 endpoint 尝试构建请求前,通过 `ResolvedTargetByEndpointUID` 取得 `*ResolvedRouteTarget`,交给 `atomicModelEffortRewrite()` 做一次原子改写:先用 `sjson` 改写 `model` 字段;若 `EffortDecided=true` 且 `Effort` 非空,再检查目标模型的 `ThinkingMode`——`adaptive_only` 的模型跳过思考参数注入(模型自身决定思考深度,守卫逻辑对齐 `providers/claude.go` 的 `applyClaudeReasoningEffort`),否则调用 `config.ApplyReasoningParamStyle()` 写入。模型改写与档位改写共用一次函数调用,任一步 `sjson`/`json` 处理失败即整体放弃、保持原始 body,不会出现\"模型改了、档位没改\"的中间态。\n\n**各协议的注入形态。** `config.ApplyReasoningParamStyle(req, style, effort)`(`config/config_utils.go`)按 `upstream.ReasoningParamStyle` 三态分支:\n\n```text\nstyle=\"thinking\" → Claude 派系:写 thinking.type=enabled + thinking.effort=;\n effort 为 off/none 时写 thinking.type=disabled;同时清理互斥字段\n reasoning / reasoning_effort / thinking.budget_tokens\nstyle=\"reasoning_effort\" → OpenAI Chat 派系:写顶层 reasoning_effort 字段,清理 reasoning\nstyle=\"\" / 默认 → Responses 派系:写 reasoning.effort 对象形态\n```\n\n**Gemini 派系是尚未接通的缺口。** 请求侧提取(`extractGeminiEffortExplicit()`,`handlers/common/reasoning_log.go`)已经能读取 `generationConfig.thinkingConfig.thinkingLevel` 并识别显式 `none`(`thinkingBudget=0`),这条读路径用于日志与 `RequestProfile.ClientEffort` 提取。但写路径——把联合决策的 `target.Effort` 写回 Gemini 请求体的 `thinkingConfig.thinkingLevel`——目前代码中不存在:`atomicModelEffortRewrite()` 唯一的写出口是 `ApplyReasoningParamStyle()`,它只覆盖 Claude/Chat/Responses 三种形态,没有 Gemini 分支;`handlers/gemini/handler.go` 的 `buildProviderRequest()` 也没有调用任何 effort 注入函数。也就是说,Gemini 渠道当前仍会经过 endpoint 级模型改写(`atomicModelEffortRewrite` 会改写 `model` 字段本身),但联合决策选中的思考档位不会落到 Gemini 请求体上,效果等同于 fail-open passthrough。此项在原计划里归属 Task 5 的\"Gemini 派系注入路径\",尚未实现。\n\n**可观测性字段。** 原子改写点会向 `gin.Context` 写入两个字段供 ChannelLog 使用:`effortDecisionSource`(`autopilot` 表示由联合决策产出,`passthrough` 表示未改写或手工映射路径)、`effortClampedByClient`(当客户端显式声明的档位低于 Autopilot 选择的档位时置 `true`,由 `isEffortClampedByClient()` 基于 `EffortLevelOrdinal` 比较得出)。\n\n**手工映射表的现状。** `resolveMappedModel()` 当前的分支顺序是\"显式 `ModelMapping` 优先,自动决策兜底\"——与原计划 Task 7 描述的\"反转优先级\"目标相反的方向仍是默认值;反转开关已作为可配置项落地(提交 `d15e776e`),但默认状态未切换,`ModelMapping` / `ReasoningMapping` 两张表本身尚未删除,`config.ResolveReasoningEffort()`(`config/config_utils.go`)与 provider 层(如 `providers/claude.go`)仍会读取 `ReasoningMapping` 独立生效。表的整体退役是 Task 8 的范围,未开始。\n\n### 5.4 模型自动映射 (ModelResolver)\n\n当请求的模型在某个渠道的 `supportedModels` 中不存在时,自动寻找最佳映射。\n\n**请求意图边界(ModelRoutingPolicy)**\n\n跨模型替代不是所有模型的默认能力,只对白名单客户端入口开放:\n\n```text\nmessages:\n fable / opus / sonnet / haiku 及对应 claude-* 完整模型名 → adaptive\nresponses:\n gpt-5.6* / gpt-5.5 / gpt-5.4* / codex-auto-review → adaptive\n其他协议或模型:\n exact-only,只能在探测到相同规范化模型 ID 的 endpoint 之间调度\n```\n\n`adaptive` 允许在满足 Capability Floor 后选择 GLM、MiMo 等其他模型族;`exact-only` 禁止跨模型替代。用户显式配置的 `modelMapping` 仍优先于该策略。调度预览和 endpoint 实际解析必须调用同一分类函数,避免诊断结果与真实请求分叉。\n\n**⚠️ 核心约束:能力下界 (Capability Floor)**\n\n模型映射最大的风险是**语义降级**:用户以为在用 opus 级能力,实际被路由到白嫖模型,输出质量下降但无信号。因此映射必须满足能力下界约束:\n\n```go\n// backend-go/internal/autopilot/model_resolver.go\n\ntype CapabilityFloor struct {\n MinContextTokens int // 请求模型的 AgentModelProfile.ContextWindowTokens\n NeedsReasoning bool // 请求模型的 ThinkingMode 非空\n NeedsVision bool // 请求包含图片\n NeedsToolCalls bool // 请求包含工具定义\n MinQualityTier QualityTier // 请求模型对应的质量档\n}\n\ntype ModelResolver struct {\n profileStore *ModelProfileStore\n}\n\n// ResolveModel 将请求模型映射到渠道实际支持的模型\n// 返回 (mappedModel, resolved, reason)\n// resolved=false 表示该渠道无满足下界约束的模型,应跳过此渠道\nfunc (r *ModelResolver) ResolveModel(\n requestModel string,\n channelUID string,\n channelKind string,\n metricsKey string,\n floor CapabilityFloor,\n) (string, bool, string) {\n // 1. 查现有 modelMapping(精确匹配 → 模糊匹配)\n // 复用 config.RedirectModel\n // 如果有显式映射,信任用户配置,不做下界检查\n // (用户手动设的映射视为已知正确)\n\n // 2. autoManaged 渠道:查 ModelProfile 表\n candidates := r.profileStore.GetModelProfiles(channelUID, channelKind, metricsKey)\n\n // 3. 硬过滤:只保留满足能力下界的模型\n eligible := filterByCapabilityFloor(candidates, floor)\n if len(eligible) == 0 {\n return \"\", false, \"no model meets capability floor\"\n }\n\n // 4. 在满足下界的模型中选优\n // 上下文窗口只负责准入,不对更大的窗口扣分。\n // 排序优先级:\n // a. 模型质量档越高越优先\n // b. provider 已确认的同档模型能力顺序\n // c. 带置信度折算的供应商实测质量\n // d. 探测延迟\n // e. 同渠道/provider 的模型相对消耗倍率\n // 倍率相同或缺失时再比较公开模型成本\n // f. 同模型族与 model ID 仅作为确定性兜底\n best := rankEligibleModels(eligible, requestModel)\n\n return best.ModelID, true, fmt.Sprintf(\"mapped %s→%s (family:%s, quality:%s)\",\n requestModel, best.ModelID, best.Family, best.QualityTier)\n}\n\n// filterByCapabilityFloor 只保留满足所有下界约束的模型\nfunc filterByCapabilityFloor(profiles []ModelProfile, floor CapabilityFloor) []ModelProfile {\n var eligible []ModelProfile\n for _, p := range profiles {\n if !p.ProbeSuccess {\n continue // 未验证通过的模型不参与自动映射\n }\n if p.ContextTokens < floor.MinContextTokens {\n continue\n }\n if floor.NeedsReasoning && !p.SupportsReasoning {\n continue\n }\n if floor.NeedsVision && !p.SupportsVision {\n continue\n }\n if floor.NeedsToolCalls && !p.SupportsToolCalls {\n continue\n }\n if qualityTierRank(p.QualityTier) < qualityTierRank(floor.MinQualityTier) {\n continue\n }\n eligible = append(eligible, p)\n }\n return eligible\n}\n```\n\n同渠道套餐倍率与公开 USD/Token 价格是不同单位,禁止直接混算。模型解析器先比较 provider\n提供的相对消耗倍率;只有双方倍率相同,或双方均无倍率时,才回退到统一输入/输出规模下的\n公开价格。优云智算当前内置倍率如下(数字越小越省,`glm-5.2` 的 2 次为限时值):\n\n| 模型 | 每次调用套餐消耗 |\n|------|-----------------:|\n| `glm-5.2` | 2 |\n| `glm-5.1` | 6 |\n| `MiniMax-M2.7` | 2 |\n| `kimi-k2.6` | 5 |\n| `deepseek-ai/DeepSeek-V3.2` | 1 |\n| `deepseek-v4-flash` | 1 |\n\nprovider 可以额外声明同一质量档内已确认的模型能力顺序,但只有该档全部候选都命中该元数据\n时才比较,避免把未知模型误判为弱模型,也保证排序不依赖候选输入顺序。优云模板当前记录\n`glm-5.1 > kimi-k2.6`,因此前者即使消耗 6 次,也会在后者的 5 次成本证据之前胜出。\n`glm-5.2` 即使消耗 2 次,也仍会在质量档高于 DeepSeek 候选时优先;V3.2 与 V4 Flash\n同为 1 次时,再由公开价格及最终确定性规则区分。静态 `modelMapping` 仅保留为用户显式覆盖,\n不承担自动替代策略。\n\n**映射示例**:\n\n| 请求模型 | 能力下界 | 渠道实际模型 | 映射依据 | 是否通过 |\n|----------|---------|-------------|---------|---------|\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-opus-4-7` | 同 opus 族,满足全部下界 | ✓ |\n| `claude-opus-4-8` | context:1M, reasoning, quality:premium | `claude-haiku-4-5` | haiku 不满足 quality:premium | ✗ 跳过渠道 |\n| `gpt-5.5` | quality:premium, reasoning | `gpt-5.4` | 同 premium 档,满足下界 | ✓ |\n| `claude-sonnet-5` | quality:high | `claude-sonnet-4-6` | 同 sonnet 族,满足下界 | ✓ |\n| 请求含图片 | vision:true | 某渠道无 vision 模型 | 不满足 vision 下界 | ✗ 跳过渠道 |\n\n#### 5.4.1 与现有模型过滤链路的关系\n\n当前调度器的 active model filter 会先调用 `upstream.ExplainModelSupport(model)`,之后才进入 `CandidateFilter`。因此自动映射不能只放在 SmartRouter 后半段,否则 channel 会在映射前被剔除。\n\nPhase 3 落地 ModelResolver 时必须调整为以下顺序:\n\n```text\n1. 构建 RequestProfile + CapabilityFloor\n2. 收集候选 channel:\n - 显式 supportedModels 支持请求模型 → 保留\n - 显式 modelMapping 可把请求模型映射到上游模型 → 保留\n - autoManaged 且存在满足 CapabilityFloor 的 ModelProfile → 保留\n - autoManaged 的空 supportedModels 不表示支持全部,必须由 endpoint ModelProfile 裁决\n - 其他 → 过滤\n3. 对每个候选 channel/endpoint 计算 request-scoped mappedModel\n4. context filter 使用 mappedModel 的实际能力做窗口校验\n5. TryUpstreamWithAllKeys 使用 EndpointAttemptPolicy 写入请求体中的实际 model\n6. 响应 header/ChannelLog 回显 originalModel 与 mappedModel\n```\n\n实现上建议新增 `ModelSupportResolver`,替代 active model filter 中对 `ExplainModelSupport` 的直接调用:\n\n```go\ntype ModelSupportResolution struct {\n Supported bool\n ActualModel string\n Source string // supported_models | explicit_mapping | auto_profile\n Reason string\n}\n```\n\n这能避免“自动映射尚未运行,channel 已被 supportedModels 过滤掉”的死路。\n\n当 ModelResolver 已正常初始化且路由处于 `assist/auto + autoResolve=true` 时,autoManaged 渠道的画像拒绝是权威结果,调度器不得再 fail-open 到空 `supportedModels` 的兼容语义。Resolver 不可用、开关关闭或 `off/shadow` 时仍保留原有 fail-open,保证急停和降级路径不依赖画像库。\n\n**映射结果回显**:\n\n这是调试的关键。映射发生时,必须在响应中标注真实使用的模型:\n\n```text\n方案 A(推荐):在 response header 中回显\n X-CCX-Mapped-Model: claude-opus-4-7\n X-CCX-Original-Model: claude-opus-4-8\n X-CCX-Mapping-Source: auto_resolved\n\n方案 B:在 Claude Messages 响应 body 的 model 字段用真实模型\n {\"model\": \"claude-opus-4-7\", ...} // 而非请求的 claude-opus-4-8\n\n方案 C:两者都做(最利于调试)\n```\n\n**安全边界**:\n- 仅 `autoManaged: true` 的渠道触发自动映射\n- 显式 `modelMapping` 始终优先,不经过下界检查(信任用户配置)\n- 请求路径中的自动映射默认是 request-scoped,不直接写回 `modelMapping`\n- Discovery 产生的映射建议可持久化;用户也可在 UI 将 request-scoped 映射保存为显式 `modelMapping`\n- 映射日志记录每次决策(requestModel → mappedModel → floor → reason),写入 ChannelLog\n- **禁止链式映射**:A→B 后不再 B→C,避免不可预测的降级链\n\n## 6. 健康诊断系统\n\n### 6.1 HealthState 状态机\n\n```text\n ┌──────────┐\n 添加渠道 →│ unknown │\n └────┬─────┘\n │ L2 探测成功 或 首次真实请求成功\n ▼\n ┌──────────┐\n ┌────────→│ healthy │←────────┐\n │ └──┬───┬───┘ │\n │ L1被动信号 │ │ L1被动信号 │ L2 探测成功\n │ 成功率↓ │ │ 连续失败≥3 │ 或 真实请求成功\n │ 429增多 │ │ │\n │ ┌──▼┐ │ ┌────┴─────┐\n │ │deg│ │ │ limited │\n │ │rad│ │ │(429/quota)│\n │ └─┬─┘ │ └────┬─────┘\n │ │ │ │\n │ L1连续≥10│ │ L1连续≥5 │ cooldown 到期\n │ 或成功率 │ │ 且全部key │ L2 探测失败\n │ <50% │ │ 认证失败 │\n │ ┌─▼──┐ │ ┌────▼─────┐\n │ │dead│◄┘ │ dead │\n │ └─┬──┘ └──────────┘\n │ │\n │ L2恢复 │ L1/L3 检测到配置错误\n │ 探测成功 │\n │ │ ┌──────────────┐\n └───────────┘ │ misconfigured│← 用户修复后 L3 重测\n └──────────────┘\n```\n\n**关键:所有正常状态转换基于 L1 被动信号,不需要额外请求。**\n\n### 6.2 诊断规则\n\n#### Dead(高置信度死亡)\n\n```text\n── 硬死(confidence >= 0.95,全部来自 L1 被动信号)──\n - 全部 Key 返回 401/403(最近 1 小时内的真实请求,FailureClass=non_retryable)\n - DNS/TLS 连接失败(ChannelLog 中 error 含 \"dial tcp\"/\"tls\"/\"certificate\")\n - 连续失败 >= 15 次(MetricsManager 滑动窗口)\n\n── 软死(confidence >= 0.80,L1 被动信号)──\n - 最近 24 小时无成功请求,且有失败记录\n - 熔断器 open 且 lastSuccessAt > 6 小时前\n - 成功率 < 10%(最近 1 小时,且请求样本 >= 5)\n\n── 确认(仅在 L1 不足时触发 L2)──\n - L1 数据不足(请求数 < 5)但 L2 探测连续失败 >= 3 次\n```\n\n#### Degraded(可用但质量差)\n\n```text\n── 全部来自 L1 被动信号 ──\n - 成功率 50%-80%(最近 1 小时,请求样本 >= 10)\n - p95 延迟 > 5000ms(最近 1 小时)\n - 断流率 > 20%(ChannelLog 中 streaming→非completed 的比率,最近 30 分钟)\n - 空响应率 > 10%(usage 全零但无报错,最近 30 分钟)\n```\n\n#### Limited(限流中)\n\n```text\n── 全部来自 L1 被动信号 ──\n - FailureClass=overloaded 占比 > 30%(最近 15 分钟)\n - Retry-After header 出现在最近 5 分钟内的 ChannelLog\n - FailureClass=quota(402/insufficient_balance/insufficient_quota)\n - 熔断器 open 但 lastSuccessAt < 6 小时前(区别于 dead)\n```\n\n#### Misconfigured(配置疑似错误)\n\n```text\n── L1 被动信号 ──\n - 全部请求返回 404(modelMapping 指向不存在的模型)\n - 501/505(协议不支持)\n - capability-test 中仅部分协议成功,但 serviceType 配的是失败协议\n\n── L3 深度探测确认(可选,用户手动触发)──\n - chat 协议成功但 serviceType 配为 claude\n - authHeader 类型与响应不匹配\n```\n\n#### Unknown(证据不足)\n\n```text\n - 新添加的渠道,无历史数据\n - 最近 24 小时内请求数 < 5 且未运行 L2 探测\n - L3 capability-test 未运行或已过期(> 7 天)\n```\n\n### 6.3 死亡类型细分\n\n```go\ntype DeathType string\nconst (\n DeathTypeHard DeathType = \"hard\" // DNS/TLS/认证(L1 被动即可判定)\n DeathTypeSoft DeathType = \"soft\" // 429/quota/临时错误(L1 被动即可判定)\n DeathTypeModel DeathType = \"model\" // 模型不可用(L1 或 L3)\n DeathTypeQuality DeathType = \"quality\" // 空响应/断流(L1 被动即可判定)\n DeathTypeUnknown DeathType = \"unknown\" // 无法分类\n)\n```\n\n**注意:Quality 死亡只检测\"硬失败\"**(空响应、断流、格式错误),不检测\"答非所问\"等软质量问题。软质量问题留给人工标签 override。\n\n### 6.4 健康诊断对调度的影响\n\n```text\nHealthState │ 调度行为 │ UI 表现\n─────────────────┼─────────────────────────────┼──────────────\nhealthy │ 正常参与调度 │ 绿色\ndegraded │ 降权,只在同池不足时使用 │ 黄色\nlimited │ cooldown 内跳过,到期 L2 复测│ 橙色\nmisconfigured │ 不参与自动调度,提示修复 │ 紫色\ndead │ 默认移出调度,建议清理 │ 红色\nunknown │ 低风险请求小流量试探 │ 灰色\n```\n\n**自动恢复机制**:\n- `limited` 渠道:cooldown 到期后触发 L2 轻量探测(单模型、最小 prompt),成功则回到 `healthy`\n- `dead` 软死渠道:每 30 分钟检查一次 L1 被动信号,如果有真实请求成功则回到 `healthy`;无真实请求时每 2 小时触发 L2 探测,连续 3 次成功则恢复\n- `dead` 硬死渠道:每 6 小时触发 L2 探测,连续 3 次成功则回到 `unknown`(不是直接 `healthy`,需真实请求验证)\n- `misconfigured` 渠道:用户修复配置后手动触发 L3 深度探测\n\n**L2 探测成本控制**:\n- 每次 L2 探测只用 1 个模型、1 个 prompt、max_tokens=50\n- 所有渠道的 L2 探测串行执行,间隔 >= 5 秒\n- 每天 L2 探测总次数上限:`渠道数 × 12`(每 2 小时最多一次)\n- 白嫖渠道的 L2 探测频率自动降低:如果连续 3 次 L2 探测失败,间隔翻倍(2h→4h→8h→24h)\n\n### 6.5 白嫖池快速衰减机制\n\n白嫖/临时池渠道的可用性是移动靶,不能靠滑动窗口慢慢反应。需要独立的衰减机制:\n\n```go\n// backend-go/internal/autopilot/fast_decay.go\n\n// FastDecayScore 实时衰减评分,用于白嫖/临时池渠道\ntype FastDecayScore struct {\n ChannelID int\n BaseScore float64 // 基于 ChannelProfile 的基础分\n DecayFactor float64 // 衰减系数 0.0-1.0\n LastUpdate time.Time\n ConsecutiveFail int\n}\n\n// OnSuccess 请求成功时\nfunc (s *FastDecayScore) OnSuccess() {\n s.DecayFactor = math.Min(1.0, s.DecayFactor+0.15) // 快速回升 +15%\n s.ConsecutiveFail = 0\n}\n\n// OnFailure 请求失败时\nfunc (s *FastDecayScore) OnFailure() {\n s.ConsecutiveFail++\n // 指数衰减:连续失败越多,衰减越快\n // 1次失败: ×0.85, 2次: ×0.72, 3次: ×0.61, 5次: ×0.44, 10次: ×0.20\n s.DecayFactor *= math.Pow(0.85, float64(s.ConsecutiveFail))\n}\n\n// OnStreamBreak 断流时(比普通失败更严重)\nfunc (s *FastDecayScore) OnStreamBreak() {\n s.ConsecutiveFail++\n s.DecayFactor *= math.Pow(0.70, float64(s.ConsecutiveFail)) // 更激进衰减\n}\n\n// EffectiveScore = BaseScore × DecayFactor\nfunc (s *FastDecayScore) EffectiveScore() float64 {\n return s.BaseScore * s.DecayFactor\n}\n```\n\n**触发条件**:`costTier=free|cheap` 或 `poolTag=temp` 的渠道自动启用 FastDecay。\n\n**调度效果**:\n- 一个白嫖渠道连续断流 3 次,EffectiveScore 从 1.0 降到 0.61,自动让位给下一个渠道\n- 连续失败 10 次,降到 0.20,几乎不会被选中\n- 成功一次立即回升 15%,避免\"一朝被蛇咬\"的永久惩罚\n- 这比滑动窗口快得多:滑动窗口需要窗口滚动才反映变化,FastDecay 是请求级即时反应\n\n### 6.9 渠道兼容性统一自学习\n\n渠道级兼容性配置不应要求用户先踩坑、再去翻设置面板找开关。这一节描述如何把这些开关从\n「静态布尔」转为「运行时学习到的能力事实」。\n\n**优先级合成**(`config.resolveCompatSwitch`)\n\n```text\n最终生效值 = 学习结论 ?? 历史种子(14 天内) ?? 静态默认\n```\n\n六个兼容性字段(`NormalizeNonstandardChatRoles`、`StripImageGenerationTool`、\n`CodexNativeToolPassthrough`、`StripEmptyTextBlocks`、`PassbackReasoningContent`、\n`PassbackThinkingBlocks`)已从 `UpstreamConfig`/`UpstreamUpdate` 整体删除:管理面板不提供编辑\n入口,渠道更新接口不接受手工写入,完全交由运行时自动决策。不再有「用户当前显式设置」这一档——\n历史手工值不代表可信的长期事实,可能当初就设错,也可能上游后来有了新情况已不再适用,因此不作为\n可以无限期压过学习的强制覆盖,只作为学习结论缺失时的一次性低置信度提示。\n\n学习结论通过 `UpstreamConfig.LearnedCompatTraits`(运行时字段,`json:\"-\"`,不落盘)传递:\nfailover 在发送前按 渠道-Key-模型 查询 `ChannelCompatCache`,把结论注入当次请求专用的\nupstream 副本,provider 构造请求时读取。\n\n**历史手工配置降级为种子**(`migrateManualCompatSwitchesToSeeds`):老用户配置文件里的六个字段\n已从结构体删除,只能在启动时从磁盘原始 JSON 里读到历史手工值。迁移把这些值降级为\n`UpstreamConfig.CompatSeeds`(`map[string]CompatSeedEntry`,带 `ExpiresAt`):\n\n- 种子带 **14 天有效期**(`CompatSeedTTL`),过期后自动失效、不再参与判断,回落学习结论或静态\n 默认重新判断。种子与学习记忆分开存储且 TTL 不同(种子 14 天 vs 学习记忆 24 小时),因为二者\n 语义不同:种子是\"曾经的一个信号\",学习记忆是\"渠道-Key-模型级的当前能力事实\"。\n- 迁移保留 `false` 值:用户「显式关闭」同样是他曾观察到的行为证据。\n- 迁移当下不改变可感知行为(学习缺失时按种子生效),但学到真实结论后立即让位,种子过期后也\n 自动退场。\n- 幂等:已有种子的渠道跳过。\n- 自动托管渠道在 `RuntimeUpstreamForAutoManagedProvider` 里连种子一起清空,其兼容性完全由\n 厂商原生默认值加运行时学习决定。已知厂商协议事实(如 GLM 的 OpenAI 兼容协议要求\n `passbackReasoningContent`)不走种子这条给\"可能出错的历史手工值\"用的临时通道,而是在对应\n accessor 里按 `ProviderID`/`ServiceType` 直接计算为静态默认(`shouldPassbackReasoningContentByDefault`\n 之类),因为这是不会过期的协议事实而非猜测。\n\n**记忆载体**:`config.ChannelCompatCache`,落盘 `.config/channel_compat.json`,\n键 `channelUID:keyHash:model`,TTL 24h(与 `DeprecatedParamCache`/`SystemHeaderFilterCache` 一致)。\n`Record` 仅在结论新增或翻转时返回 `true`,调用方据此决定是否重试,防止记忆生效后死循环。\n\n**三条学习路径**(按上游信号强度区分,不可一概而论)\n\n| 信号类型 | 兼容项 | 学习方式 |\n| --- | --- | --- |\n| 硬 400/422 + 可正则匹配 | developer role 降级、Codex 专属工具剥离、历史 thinking 回传、图片工具剥离 | 错误驱动:`CompatTraitFromError` 识别 → 记忆 → 同 Key 立即重试 |\n| 无报错,仅响应形态差异 | `passbackReasoningContent`、`stripEmptyTextBlocks` | 探针驱动:首次遇到该组合时 singleflight 后台探测一次 |\n| 厂商文档已公布约束 | Kimi 固定值参数、`tool_choice`、`thinking` | 前置规避:`ApplyKnownParamConstraints` 发送前直接改写,无需失败一次 |\n\n**分层职责**:`upstream_failover` 负责识别信号、写缓存、编排重试、把结论注入 `upstreamCopy`;\n各 provider 在构造上游请求时读取合成值并执行改写。这样 developer role 降级发生在\nResponses→Chat 转换**之后**的 `reqMap` 上,避开「failover 层拿到的是 Responses `input` 而非\nChat `messages`」这个陷阱。\n\n**防误判约束**(学错并持久化比不学更糟)\n\n- 双条件门控:正则命中之外,还要求请求侧确实携带对应结构(`BodyHasDeveloperRole` 等)\n- 单次重试上限:`Record` 返回 `false` 即不再重试\n- 不跨维度污染:结论不外溢到同渠道其他 Key/模型\n- 仅 400/422 参与学习;429/5xx/超时属容量问题,不是能力问题\n- fail-open:缓存损坏、探测失败一律退化为静态默认,不阻断代理请求\n\n**图片工具归因修正**:图片生成受限时,原实现直接拉黑 (Key,模型)。现改为先学习\n「需剥离图片工具」并重试修复请求,只有剥离后仍失败才回落拉黑——从「惩罚渠道」改为「先修复请求」。\n\n## 7. API 设计\n\n### 7.1 新增 API 端点\n\n#### 渠道画像\n\n```text\nGET /api/{kind}/channels/profiles → 获取所有渠道画像\nGET /api/{kind}/channels/{id}/profile → 获取单个渠道画像\nPOST /api/{kind}/channels/{id}/profile/refresh → 手动刷新画像\n```\n\n#### 模型画像\n\n```text\nGET /api/{kind}/channels/{id}/model-profiles → 获取渠道下所有模型画像\n```\n\n#### 本地模型运行时\n\n```text\nGET /api/local-models/runtimes → 获取本地运行时列表\nPOST /api/local-models/runtimes → 添加 Ollama/LM Studio/llama-server\nPOST /api/local-models/runtimes/{uid}/probe → 探测本地模型能力/延迟\nPOST /api/local-models/runtimes/{uid}/benchmark → 本地 runtime 性能与能力基准\nPOST /api/smart-routing/trusted-advisor/benchmark → 对一等官方或本地 advisor 做 shadow 评估\nGET /api/smart-routing/trusted-advisor/summary → 查看可信 advisor 准确率、成本与命中情况\n```\n\n#### 健康中心\n\n```text\nGET /api/health-center/overview → 全局健康概览(跨所有 kind)\nGET /api/health-center/channels → 渠道健康列表(支持过滤/排序)\nPOST /api/health-center/batch-action → 批量操作(refresh/probe/pause)\nPOST /api/health-center/channels/{id}/probe → 手动深度探测\nGET /api/health-center/provider-quality/budget → 查看本进程手动 L3 每日预算\nPOST /api/health-center/provider-quality/probe → 对指定 endpoint×model 执行固定质量 canary\n```\n\n#### 订阅中心\n\n```text\nGET /api/subscriptions → 获取订阅/套餐列表\nPOST /api/subscriptions → 创建订阅/套餐\nPUT /api/subscriptions/{uid} → 更新订阅/套餐\nPOST /api/subscriptions/{uid}/link-channel → 绑定渠道\nPOST /api/subscriptions/{uid}/refresh → 手动刷新余额/套餐状态(可选 provider adapter)\n```\n\n#### 自动托管\n\n```text\nPOST /api/{kind}/channels/auto-add → 自动添加渠道(仅需 URL+Key)\nPOST /api/{kind}/channels/{id}/auto-discover → 重新触发自动发现\nGET /api/{kind}/channels/{id}/auto-status → 自动托管状态\n```\n\n#### 智能路由(诊断用)\n\n```text\nPOST /api/smart-routing/diagnose → 智能路由诊断(dry-run)\nGET /api/smart-routing/config → 获取自动路由配置\nPUT /api/smart-routing/config → 更新自动路由配置\nGET /api/smart-routing/intents → 获取人工路由意图列表\nPOST /api/smart-routing/intents → 创建模型/渠道/endpoint 试用意图\nPUT /api/smart-routing/intents/{uid} → 禁用或延长试用意图\nGET /api/smart-routing/intents/{uid}/result → 查看试用结果摘要\n```\n\n### 7.2 与现有 API 的关系\n\n| 现有端点 | 变更 |\n|---------|------|\n| `POST /channel-discovery` | 不变,autopilot 内部复用其逻辑 |\n| `POST /{kind}/channels` | 新增 `autoManaged` 字段,为 true 时自动触发 Discovery |\n| `POST /{kind}/channels/{id}/capability-test` | 不变,autopilot 结果写入 ModelProfile |\n| `POST /{kind}/channels/scheduler/diagnose` | 增加智能路由 trace 输出 |\n| `GET /{kind}/channels/dashboard` | 增加 `healthState`、`qualityTier`、`originTier`、`subscriptionUid` 字段 |\n| `X-Channel` / manual override / promotion | 保留;新增 `ManualRoutingIntent` 在产品层表达短期试用,不替代底层显式控制 |\n| 本地 OpenAI-compatible runtime | 可作为普通 channel,也可注册为 `LocalModelRuntimeProfile`;如角色包含 `routing_advisor`,可被 `TrustedRoutingAdvisor` 选择 |\n| `/v1/images/*` / `/v1/embeddings` | 纳入原生 endpoint 调度;MVP 不做 chat 画图转换,只做健康/成本/RPM/endpoint 排序 |\n\n### 7.3 WebSocket 推送\n\n新增 `ws://api/autopilot/events` 通道,推送:\n\n```json\n{\n \"type\": \"profile_updated\",\n \"channelId\": 5,\n \"channelKind\": \"messages\",\n \"healthState\": \"dead\",\n \"suggestedAction\": \"delete\",\n \"evidence\": [\"5/5 keys returned 401\"]\n}\n```\n\n事件类型:`profile_updated` / `health_changed` / `discovery_completed` / `auto_mapping_applied`\n\n### 7.4 安全与权限边界\n\n新增端点必须按管理接口处理:\n\n- 所有 `/api/health-center/*`、`/api/smart-routing/*`、`/api/{kind}/channels/*/auto-*` 端点必须要求 `ADMIN_ACCESS_KEY` 或等价管理权限。\n- 创建 `ManualRoutingIntent` 必须要求管理权限;如果影响 main/supervisor 或 unknown model,UI 必须显示 TTL、预算和 fallback 状态。\n- 添加本地运行时只允许 loopback/private network baseURL,除非用户显式开启远程本地运行时;避免 SSRF 风险。\n- routing advisor 会读取用户 prompt/metadata,因此只能调用 `OriginTier=first` 或 `OriginTier=local`;严禁把额外判定上下文发送给中转站、公益站或未知来源。\n- advisor 输入必须走最小上下文:只传分类所需的 prompt 摘要、任务 metadata 和能力需求,不传 API Key、Authorization、自定义敏感 header 或完整历史,除非用户显式允许。\n- `batch-action` 默认只允许 `refresh`、`probe`、`pause`,不默认开放删除;删除必须走现有单渠道删除流程并二次确认。\n- WebSocket 必须鉴权,事件中禁止携带明文 API Key、Authorization header、自定义敏感 header、multipart 内容。\n- 手动深度探测会请求真实上游,UI 必须显示本次操作会消耗上游额度;后台自动 L2 探测遵守全局预算。\n- `auto-add` 写入配置前只保存用户提交的必要字段;探测日志只记录 key mask 和 metricsKey。\n\n---\n\n## 8. 前端设计\n\n### 8.0 信息架构联动\n\nAutopilot 会影响四个一线界面,必须按职责拆清楚,避免所有信息都堆到渠道列表:\n\n| 页面 | 职责 | 主要数据 | 不负责 |\n|------|------|----------|--------|\n| 渠道中心 | 管上游 endpoint 的可用性、能力、信任等级和调度状态 | ChannelProfile、KeyEndpointProfile、HealthState、OriginTier、SubscriptionUID | 不维护套餐余额,不计算全局花费预算 |\n| 订阅中心 | 管官方 API/token plan、中转套餐、公益来源、余额与倍率 | SubscriptionProfile、余额、续费周期、GroupMultipliers、RechargeMultiplier | 不展示每个 endpoint 的实时健康细节 |\n| 管理面板 | 管全局策略、权限、安全开关、本地模型和 Autopilot 模式 | smartRouting 配置、探测预算、自动 RPM、可信 advisor、成本优化开关 | 不承载日常运营决策 |\n| 驾驶舱 | 给出当前系统是否健康、是否省钱、是否需要人工处理 | 跨渠道聚合指标、成本趋势、异常队列、调度 dry-run 摘要、ManualRoutingIntent 摘要 | 不编辑复杂渠道配置 |\n\n核心数据流:\n\n```text\n订阅中心:套餐/倍率/余额/信任等级\n ↓ SubscriptionUID\n渠道中心:endpoint 健康/能力/质量/成本画像\n ↓ ChannelProfile + CostProfile\nSmartRouter:按任务类型、硬约束、实时质量和有效成本排序\n ↓ 运行指标\n驾驶舱:总览、异常、节省金额、人工待办\n```\n\n界面原则:\n- 信任等级始终单独展示为 `官方 / 官方 token plan / 中转 / 公益 / 本地 / 未知`,不和质量 badge 合并。\n- 服务质量始终来自运行画像:成功率、流式完整性、p95 延迟、HealthState、QualityTrend。\n- 当低信任等级渠道短时间服务更好时,驾驶舱和渠道中心都应显示「低信任来源当前表现更优」,但仍禁止它承担隐私敏感的 AI 判定。\n- 所有价格、余额、充值倍率优先从订阅中心读;渠道/key 只保留必要 override。\n\n#### 8.0.1 实现现状与演进方向(2026-07-09 核实)\n\n四界面在 **Web 前端(`frontend/src`)已大面积落地**,不是纯文档:\n\n| 界面 | Web 实现 | Desktop 实现 |\n|------|----------|--------------|\n| 健康中心 | ✅ `HealthCenterView.vue` + `HealthCenterStats`/`HealthChannelTable`/`HealthChannelDetail`/`ProfileChangelogTimeline`,路由 `/health` | ❌ 无 |\n| 订阅中心 | ✅ `SubscriptionsView.vue` + `SubscriptionPlanTable`,CRUD + 刷新余额,路由 `/subscriptions` | ⚠️ 独立旧实现 `subscriptions/SubscriptionTab.vue`(与 Web 两套代码) |\n| 渠道中心 | ✅ `ChannelOrchestration.vue`(六态 `ChannelHealthBadge`、来源 chips、`autoManaged` 图标齐备) | ❌ 未同步 |\n| 管理面板 | ⚠️ `AutopilotModePanel.vue` 仅暴露 mode/killSwitch/costPreference | ❌ 无 |\n| 驾驶舱 | ✅ `CockpitView.vue`(观测型总览:健康统计+余额汇总+渠道推荐) | ⚠️ `cockpit` tab 渲染的是会话 Dashboard,职责不同 |\n\n**与本设计意图的关系**(对齐用户「自动调度为主、废旧手动驾驶舱」的诉求):\n- **旧的「手动调度驾驶舱」在 Web 端已不存在**。`CockpitView` 已是 §8.7 定义的观测型总览,只回答健康/省钱/风险/待办四问题,不做渠道配置编辑,符合「程序自动选、人只观测+有限干预」的方向。无需再废除,只需继续收敛为纯观测。\n- **统一提交入口已存在**(§8.4 快速添加 + §8.5 订阅中心),new-api 集成(§8.5.1)在此之上把「填 baseURL+key」进一步自动化。\n\n**前端演进待办**(本设计新增/明确):\n1. **补 `ManualRoutingIntent` 前端**:这是用户要的「有限偏向性干预」,后端已有、两个前端均未实现(§8.3 试用意图入口尚缺)。\n2. **Desktop 前端对齐 Web**:健康中心/渠道自动托管/管理面板/观测型驾驶舱在 Desktop 几乎空白,需同步或复用 Web 组件,避免长期维护两套。\n3. **管理面板补字段**:暴露 `l2ProbeEnabled`、探测预算、new-api/多来源订阅 adapter 开关(§8.5.2)等,当前只有 3 个字段。\n4. **订阅中心接 §8.5.1/§8.5.2**:新增「new-api」订阅类型与 Provider Adapter 能力矩阵驱动的动态表单。\n\n### 8.1 健康中心视图\n\n新增 `HealthCenter.vue` 页面,作为渠道中心的高级视图。\n\n#### 布局\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 渠道健康中心 [批量复测] [批量暂停] [筛选] │\n├──────────────────────────────────────────────────────────┤\n│ ┌─ 统计卡片 ────────────────────────────────────────────┐│\n│ │ 🟢 12 健康 🟡 3 降级 🟠 5 限流 🔴 4 死亡 ⚪ 2 新 ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 分组标签 ────────────────────────────────────────────┐│\n│ │ [官方(5)] [Token Plan(3)] [中转(18)] [公益(6)] ││\n│ │ [建议清理(4)] [需要修复(3)] [限流恢复中(5)] ││\n│ │ [质量较差(2)] [观察池(2)] [当前表现优于等级(3)] ││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道表格 ────────────────────────────────────────────┐│\n│ │ 状态 │ 来源 │ 渠道 │ 订阅 │ 协议 │ 模型数 │成功率│p95│ │\n│ │ 🔴 │ 中转 │ xxx │ A套餐│ chat │ 3/5 │ 2% │ - │ │\n│ │ 🟢 │ 公益 │ yyy │ 免费 │ msgs │ 7/7 │ 99% │1s │ │\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 渠道详情侧栏(点击展开)─────────────────────────────┐│\n│ │ 健康状态:🔴 Dead (confidence: 96%) ││\n│ │ 死亡类型:硬死 - 全部 Key 认证失败 ││\n│ │ 证据: ││\n│ │ • 5/5 keys returned 401 ││\n│ │ • capability-test failed 3 consecutive times ││\n│ │ • no successful request in 72h ││\n│ │ 建议操作:[替换 Key] [删除渠道] [标记观察] ││\n│ │ 来源:中转 second,订阅:A 套餐 ││\n│ │ 画像:quality=high, stability=unstable, speed=- ││\n│ │ 可用模型:gpt-5.4(✓), gpt-5.5(✗), ... ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n#### 标签系统\n\n每个渠道显示标签 chip:\n\n| 标签 | 颜色 | 条件 |\n|------|------|------|\n| 官方 API | 蓝 | originType=official_api |\n| 官方 Token Plan | 蓝 | originType=official_token_plan |\n| 中转站 | 靛 | originType=relay |\n| 公益站 | 绿 | originType=community |\n| 来源未知 | 灰 | originType=unknown |\n| 高智商稳定 | 蓝 | qualityTier=high\\|premium + stabilityTier=stable |\n| 白嫖池 | 绿 | costTier=free |\n| 临时池 | 橙 | 画像来源=auto_probe 且 confidence < 0.7 |\n| 当前表现优于等级 | 青 | originTier=second\\|third 且 successRate15m/p95/streamHealth 优于一等来源中位数 |\n| 仅子代理 | 灰 | qualityTier=low + costTier=free\\|cheap |\n| 可识图 | 紫 | supportsVision=true |\n| 长上下文 | 青 | supportsLongCtx=true |\n| 全部 Key 失效 | 红 | evidence 含 \"all keys failed\" |\n| 限流中 | 黄 | healthState=limited |\n| 疑似配置错 | 紫 | healthState=misconfigured |\n\n### 8.2 渠道卡片增强\n\n在现有 `ChannelOrchestration.vue` 的每行中增加:\n\n1. **健康状态 badge**:替换现有简单状态,使用 HealthState 六态 badge\n2. **来源 badge**:显示官方 API / 官方 Token Plan / 中转 / 公益 / 未知,并支持跳转到订阅中心\n3. **质量/稳定性/速度/成本标签**:在渠道名下方显示小 chip,和来源 badge 分开\n4. **自动托管图标**:`autoManaged` 渠道显示机器人图标,hover 提示「自动托管中」\n5. **一键操作**:死渠道显示「清理」快捷按钮\n6. **有效成本预览**:在 key 详情中显示 `groupMultiplier / rechargeMultiplier = effectiveMultiplier` 和按模型估算的每百万 token 成本\n7. **信任等级质量对比**:当低信任来源短时间优于一等来源中位数时,显示「当前表现更好」状态,但不改变其信任等级\n8. **试用入口**:对新公益站/新 endpoint 显示「试用」按钮,打开 ManualRoutingIntent 创建面板\n\n### 8.3 人工干预入口\n\n人工干预不放在全局优先级里,而是作为短期试用意图创建:\n\n```text\n┌─ 创建试用意图 ─────────────────────────┐\n│ 类型:[模型试用 ▾] │\n│ 模型:[fable-5____________________] │\n│ 渠道:[自动选择 / 指定渠道 / 指定endpoint] │\n│ 范围:[当前会话] [子代理] [轻任务] │\n│ 流量:[10%───] 上限:[100 请求] │\n│ 有效期:[2 小时 ▾] 失败回退:[开] │\n│ 风险:未知模型不会写入全局 modelMapping │\n│ [创建试用] │\n└────────────────────────────────────────┘\n```\n\n入口:\n- 渠道中心:在渠道/endpoint 行上创建 `channel_trial` 或 `endpoint_trial`,适合“公益站先用起来”。\n- 模型画像:在未知或新发现模型上创建 `model_trial`,适合测试 `fable-5`。\n- 驾驶舱:显示活跃试用、预算消耗、成功率、fallback 次数和一键结束。\n- 会话详情:创建 `session_pin`,只影响当前会话排障。\n\n默认值:\n- `model_trial` 默认 2 小时或 100 请求,`FallbackOnFailure=true`。\n- `channel_trial` 默认只作用于 worker/lightweight,不覆盖 supervisor。\n- third-tier 公益站试用默认 `trafficPercent<=25`,除非用户显式提高。\n- 试用结束只生成建议,不自动保存长期映射或优先级。\n\n### 8.4 添加渠道流程简化\n\n当用户选择「快速添加」模式:\n\n```text\n┌─ 快速添加渠道 ──────────────────────────┐\n│ │\n│ 名称:[________________] (可选,自动生成) │\n│ 来源:[官方API ▾] 订阅:[新建/选择____] │\n│ 地址:[https://xxx/v1________________] │\n│ [+ 添加 BaseURL] │\n│ Key :[sk-__________________________] │\n│ [+ 添加 Key] │\n│ │\n│ Key 成本(可选,展开编辑) │\n│ ┌ key mask │ 分组倍率 │ 充值倍率 │ 预估成本 ││\n│ │ sk-***a │ *:1.0 │ 1.0 │ 1.0x ││\n│ │ sk-***b │ opus:1 │ 2.0 │ 0.5x ││\n│ └──────────────────────────────────────┘│\n│ 信任说明:信任等级只表示隐私/治理风险,不代表实时质量 │\n│ │\n│ [x] 自动托管(推荐) │\n│ 系统将自动探测协议、发现模型、 │\n│ 生成映射、持续监控健康 │\n│ │\n│ [添加并探测] │\n└──────────────────────────────────────────┘\n```\n\n点击「添加并探测」后:\n1. 创建渠道(status=unknown)\n2. 自动触发 Discovery + 能力测试\n3. 显示进度条和实时探测日志\n4. 完成后生成映射建议;仅当所有 endpoint 结果一致时自动写入 channel 级 modelMapping / supportedModels / 兼容开关\n5. 生成初始 ChannelProfile\n6. 如果用户选择/创建了订阅,自动建立 `SubscriptionUID → ChannelUID` 链接\n\n### 8.5 订阅中心\n\n新增 `SubscriptionsView.vue`,统一管理官方 API、官方 token plan、中转套餐和公益来源。它不是支付系统,只是本地账单和来源画像的事实源。\n\n```text\n┌──────────────────────────────────────────────────────────┐\n│ 订阅中心 [新增订阅] [导入] │\n├──────────────────────────────────────────────────────────┤\n│ 类型筛选:[全部] [官方API] [Token Plan] [中转] [公益] │\n│ │\n│ ┌─ 套餐表 ──────────────────────────────────────────────┐│\n│ │ 类型 │ 名称 │ 余额 │ 倍率 │ 绑定渠道 │ 最近使用 │ 状态 ││\n│ │ 官方 │ OpenAI Prod │ $42 │ *:1.0/1.0 │ 3 │ 2m前 │正常││\n│ │ 中转 │ A站充值组 │ ¥88 │ opus:1/2 │ 8 │ 1m前 │正常││\n│ │ 公益 │ 临时公益池 │ - │ free │ 4 │ 5m前 │波动││\n│ └───────────────────────────────────────────────────────┘│\n│ │\n│ ┌─ 详情 ────────────────────────────────────────────────┐│\n│ │ 价格公式:effective = groupMultiplier / recharge ││\n│ │ 继承链:subscription → channel → key ││\n│ │ 绑定渠道:[messages/0] [responses/2] [chat/1] ││\n│ │ 操作:[刷新余额] [绑定渠道] [编辑倍率] [归档] ││\n│ └───────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────┘\n```\n\n订阅中心规则:\n- `official_api` 和 `official_token_plan` 默认是 `OriginTier=first`,但 UI 必须显示具体计费模式。\n- 中转站套餐必须支持 key 级分组倍率和充值倍率批量编辑,写入 `SubscriptionProfile` 后由 key 继承。\n- 公益站允许没有余额和价格,仍可维护来源、备注、RPM 建议和风险说明。\n- 一个订阅可绑定多个渠道;一个渠道默认只绑定一个订阅。需要多个来源时建议拆 channel。\n- 余额刷新 adapter 是可选能力;MVP 允许手动录入余额和更新时间。\n\n### 8.5.1 new-api 订阅集成(统一账号接入)\n\n**目标**:用户在订阅中心手填一个 new-api(及其 fork)账号的「系统访问令牌」即可,CCX 自动用该令牌创建代理专用 key、拉取分组倍率、查询额度余额,并把「站点 baseURL + 新建 key」自动落成一个上游渠道,交给 Autopilot 探测与调度。\n\n**范围约束**:\n- 只使用 new-api 用户在「个人设置」生成的 **系统访问令牌(access_token)** 一种凭据;令牌用于 API 调用的身份验证,属敏感数据,须妥善加密存储。\n- **不做** cookie 登录 + 2FA + 导出后台渠道明文 key 的管理员迁移路径(all-api-hub 的 newApiSession 能力),本设计不涉及。\n- new-api 归入 `OriginType=relay`、`OriginTier=second`(中转站),信任等级不因自动化提升;实时质量仍由运行画像决定。\n- 认证令牌像其他订阅凭据一样由用户手动录入,CCX 不代替用户在 new-api 侧注册或登录。\n\n**认证与凭据模型**:\n\nnew-api family 的管理接口鉴权(依据 all-api-hub 传输层实现):\n\n```text\nAuthorization: Bearer # 系统访问令牌;raw 模式可去掉 Bearer 前缀\nNew-API-User: # 当前用户 ID(必带)\nUser-id: # fork 兼容回退(Veloera/voapi/Super-API 等)\nContent-Type: application/json\n```\n\n统一响应信封:`{ \"success\": bool, \"data\": T, \"message\": string }`;解析时先判 `success`,再取 `data`。\n\n**SubscriptionProfile 扩展**:\n\n在 §3.2.2 的 `SubscriptionProfile` 基础上,为可自动接入的订阅新增 provider 集成字段(沿用「默认关闭 + 显式 opt-in」守则):\n\n```go\n// SubscriptionProfile 新增字段(复用 Phase 4 Item 6 的 SubscriptionAutoRefresh 开关)\ntype SubscriptionIntegration struct {\n Kind string `json:\"kind\"` // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | ...\n BaseURL string `json:\"baseUrl\"` // new-api 站点地址(同时作为新建渠道的上游 baseURL)\n AccessToken string `json:\"-\"` // 系统访问令牌;加密存储,不出 API 响应\n UserID string `json:\"userId\"` // New-API-User 头所需\n AuthTokenMode string `json:\"authTokenMode,omitempty\"` // \"bearer\"(默认) | \"raw\"\n // 自动建 key 的模板\n ProvisionKeyName string `json:\"provisionKeyName,omitempty\"` // 默认 \"ccx-autopilot\"\n ProvisionGroup string `json:\"provisionGroup,omitempty\"` // 建 key 时指定分组,空=默认分组\n ProvisionModels []string `json:\"provisionModels,omitempty\"` // model_limits,空=不限制\n // 自动同步产物(回填,只读展示)\n ProvisionedTokenID int `json:\"provisionedTokenId,omitempty\"`\n LinkedChannelUID string `json:\"linkedChannelUid,omitempty\"`\n}\n```\n\n`AccessToken` 必须与现有敏感字段一样走加密存储,序列化到前端时脱敏(仅显示尾部若干位)。\n\n**new-api 接口清单**(本集成实际调用的子集,路径以主线 new-api 为准,字段名保持与上游一致):\n\n| 用途 | 方法 | 路径 | 请求体 | 响应关键字段 |\n|------|------|------|--------|--------------|\n| 校验令牌 + 查用户/余额 | GET | `/api/user/self` | — | `data.id`、`data.username`、`data.quota`(剩余额度)、`data.used_quota` |\n| 拉分组倍率 | GET | `/api/user/self/groups` | — | `data: { : { desc, ratio } }` |\n| 站点全部分组 | GET | `/api/group` | — | `data: string[]` |\n| 账号可用模型 | GET | `/api/user/models` | — | `data: string[]` |\n| 创建代理 key | POST | `/api/token/` | `CreateTokenRequest`(见下) | `data.key` / `success` |\n| key 列表 | GET | `/api/token/?p={p}&size={n}` | — | `data.items[]`(含 `id/key/name/status/remain_quota`) |\n| 更新 key | PUT | `/api/token/` | `{ ...token, id }` | `success` |\n| 删除 key | DELETE | `/api/token/{id}` | — | `success` |\n\n`CreateTokenRequest`:\n\n```json\n{\n \"name\": \"ccx-autopilot\",\n \"remain_quota\": 0,\n \"expired_time\": -1,\n \"unlimited_quota\": true,\n \"model_limits_enabled\": false,\n \"model_limits\": \"\",\n \"allow_ips\": \"\",\n \"group\": \"\"\n}\n```\n\n`quota` 是 new-api 内部额度单位(非美元),换算比例读 `GET /api/status` 的 `price` / `PaymentUSDRate`(无鉴权),或允许用户在订阅中手填换算率;MVP 直接以 quota 原值展示 + 站点换算率估算余额。\n\n**接入流程**(订阅中心「新增订阅 → 类型选 new-api」):\n\n```text\n1. 用户输入:站点 baseURL + 系统访问令牌(access_token) + userId\n (userId 可选自动获取:先用令牌调 /api/user/self 拿 data.id 回填)\n2. 校验:GET /api/user/self\n ├─ success=false / 401 → 令牌无效或 userId 不匹配,报错停止\n └─ 成功 → 记录 username、quota,创建 SubscriptionProfile(provider=new_api, originTier=second)\n3. 拉分组:GET /api/user/self/groups → 写入 GroupMultipliers({group: ratio})\n GET /api/user/models → 记录账号可用模型,供渠道 supportedModels 参考\n4. 建 key:POST /api/token/(按 ProvisionKeyName/Group/Models 模板)\n ├─ 已存在同名 ccx-autopilot key → 复用其 id,不重复创建(列表比对 name)\n └─ 新建成功 → 回填 ProvisionedTokenID,取 data.key(补齐 sk- 前缀)作为渠道 apiKey\n5. 写入渠道:同 baseURL+channelKind 已有渠道时并入该渠道(多 new-api 订阅与纯 key 整合为同一渠道),否则自动新建\n ├─ 合并目标匹配:BaseURL/BaseURLs 规范化(去尾部 /,忽略大小写)相等;跳过带 providerId 的渠道;多个命中优先 active\n ├─ 合并:保留已有纯 key 与配置,新 key 按字符串去重后追加,写入 quotaGroup/groupMultiplier 元数据\n ├─ 新建:channelKind 由用户选择或按站点探测(messages/chat/responses/gemini),status=unknown,autoManaged=true,OriginType=relay\n ├─ 建立 SubscriptionUID → ChannelUID 链接(回填 LinkedChannelUID)\n └─ 触发 §8.4 的 Discovery + 能力测试标准流程\n6. 完成:订阅卡片显示余额、分组倍率、已绑定渠道、探测进度\n```\n\n关键点:new-api 集成**不额外造探测逻辑**,第 5 步之后完全复用现有 §8.4「添加并探测」链路;集成层只负责「令牌 → key + 分组 + 余额 + baseURL」的自动填充,把手填 baseURL/key 的动作自动化掉。\n\n**刷新与同步**(复用 `POST /api/subscriptions/{uid}/refresh`):\n\n| 触发 | 动作 |\n|------|------|\n| 手动刷新余额 | `GET /api/user/self` 更新 `Balance` + `BalanceUpdatedAt` |\n| 定时刷新(可选,opt-in) | 复用 Phase 4 Item 6 `SubscriptionAutoRefresh`;new-api 作为 `relay` provider adapter 纳入,默认关闭 |\n| 分组倍率变更 | 刷新时对比 `/api/user/self/groups`,`GroupMultipliers` 变化 → 更新并提示「套餐倍率已变」 |\n| key 失效检测 | 渠道侧探测到该 key 全 401 → 订阅卡片提示「key 可能已删除/禁用」,提供「重新建 key」一键动作(重跑第 4-5 步,复用旧渠道,只换 apiKey) |\n| 可用模型变更 | `/api/user/models` 结果哈希变化 → 触发对应渠道重新 Discovery |\n\n**安全与边界**:\n- `access_token` 权限等同 new-api 账户,须加密存储、脱敏展示、禁止写入日志;刷新失败连续 N 次自动停用该订阅的自动刷新,只保留手动。\n- 自动建 key 默认 `unlimited_quota=true` 但可由用户改为限额模板;建 key 是有副作用的写操作,须在接入前明确告知用户「将在 new-api 侧创建一个名为 ccx-autopilot 的令牌」。\n- new-api 是 `OriginTier=second`,**不得**用作隐私敏感的 routing advisor / classifier(遵守 §3.2.1 约束)。\n- fork 兼容:请求同时带 `New-API-User` 与 `User-id` 头;响应信封字段名(`success/data/message`)在部分 fork 可能不同,adapter 需容错解析,失败时回退手动录入。\n- 一个 new-api 订阅默认只自动建/绑定一个渠道;用户如需该站多协议渠道,可在同订阅下手动增建,共用同一 key。\n\n### 8.5.2 订阅 Provider Adapter 框架(多来源余额与接入)\n\n§8.5.1 的 new-api 集成不是特例,而是「订阅 Provider Adapter」框架的一个实现。Phase 4 Item 6 已落地 openai/anthropic/google 的余额刷新 adapter,本框架把它抽象成统一接口,让 new-api 及后续来源(GitHub Copilot、Codex、Nvidia、MiniMax、DeepSeek 等)按相同契约接入,避免每加一个来源就散落一套逻辑(DRY)。\n\n**统一接口**:\n\n```go\n// backend-go/internal/autopilot/subscription/adapter.go\ntype ProviderAdapter interface {\n Kind() string // \"new_api\" | \"openai\" | \"anthropic\" | \"google\" | \"copilot\" | \"deepseek\" | ...\n\n // 校验凭据,返回账号身份(用户名/账户标识);失败即凭据无效\n Verify(ctx context.Context, cred Credential) (AccountInfo, error)\n // 查余额/额度;不支持返回 ErrUnsupported,由 UI 降级为手动录入\n FetchBalance(ctx context.Context, cred Credential) (Balance, error)\n // 拉分组倍率;仅 new-api 类中转有,官方直连返回 ErrUnsupported\n FetchGroups(ctx context.Context, cred Credential) (map[string]GroupRatio, error)\n // 自动创建代理 key;官方直连通常不支持(key 即用户 API key),返回 ErrUnsupported\n ProvisionKey(ctx context.Context, cred Credential, tmpl KeyTemplate) (ProvisionedKey, error)\n // 可用模型列表;用于渠道 supportedModels 参考\n FetchModels(ctx context.Context, cred Credential) ([]string, error)\n}\n```\n\n每个能力独立、可选:adapter 只实现自己支持的部分,不支持的统一返回 `ErrUnsupported`(接口隔离原则),UI 据此隐藏/降级对应入口。所有 adapter 共用 `SubscriptionAutoRefresh` 的 opt-in 开关与 `Validate()` 兜底,与 Phase 4 现有防御模式一致。\n\n**能力矩阵**(✅ 支持 / ⚠️ 部分或需确认 / ❌ 不支持,走手动):\n\n| 来源 | Kind | 验证 | 查余额 | 拉分组 | 自动建 key | OriginTier |\n|------|------|:----:|:------:|:------:|:----------:|------------|\n| new-api family | `new_api` | ✅ | ✅ quota | ✅ | ✅ | second |\n| OpenAI 官方 | `openai` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Anthropic 官方 | `anthropic` | ✅ | ⚠️ 计费 API | ❌ | ❌ | first |\n| Google 官方 | `google` | ✅ | ⚠️ | ❌ | ❌ | first |\n| GitHub Copilot | `copilot` | ⚠️ token | ❌ 无额度概念 | ❌ | ❌ | first |\n| Codex plan | `codex` | ⚠️ | ⚠️ 用量窗口 | ❌ | ❌ | first |\n| Nvidia NIM | `nvidia` | ✅ | ⚠️ credits | ❌ | ❌ | first |\n| MiniMax | `minimax` | ✅ | ⚠️ | ❌ | ❌ | first |\n| DeepSeek | `deepseek` | ✅ | ✅ balance API | ❌ | ❌ | first |\n\n说明:官方直连来源普遍**不支持自动建 key**(用户的 API key 本身就是凭据,直接作为渠道 apiKey 录入即可)、也**没有分组倍率**(价格按官方定价表)。它们的 adapter 价值集中在「验证 + 查余额/用量窗口」,把 §3.2.4 UsageWindow 的官方用量 API 来源坐实。DeepSeek 有独立余额查询端点,可完整支持余额。Copilot 属订阅制、无 token 余额概念,只做验证 + 用量提示。\n\n**接入分层**(YAGNI:不强求一次做全,按价值排期):\n\n```text\nP0(本轮):new_api(完整:验证+余额+分组+建key+自动建渠道)\n —— 覆盖用户最主要的中转站场景\nP1:deepseek(验证+余额)、官方三家余额已在 Phase 4 完成 → 补 UsageWindow 对齐\nP2:nvidia / minimax(验证+credits/余额)\nP3:copilot / codex(验证+用量窗口提示,无余额)\n```\n\n每个来源的具体端点在实现时于 `adapter.go` 各 provider 文件内落地,本设计只固定接口契约与能力矩阵,避免文档与上游 API 变动强耦合。新增来源 = 实现 `ProviderAdapter` + 在能力矩阵登记,不改框架。\n\n**能力缺失与降级**:\n- adapter 任一能力返回 `ErrUnsupported` → 订阅中心对应字段回退为手动录入(余额手填、倍率手填),不报错、不阻塞接入。\n- adapter 验证失败/超时 → 订阅可仍以「手动模式」创建,仅失去自动刷新与自动建 key,其余流程(手填 baseURL+key 建渠道)不受影响。\n- 官方来源无「自动建 key」时,接入流程退化为 §8.4 标准快速添加:用户填 API key,adapter 只补验证与余额。\n- 所有自动刷新遵守 `SubscriptionAutoRefresh` 默认关闭;连续失败自动停用自动刷新并在驾驶舱「人工待办」提示。\n\n### 8.6 管理面板\n\n管理面板新增 Autopilot 策略页,承载全局开关,不进入日常渠道运营路径:\n\n| 区块 | 控件 | 默认 |\n|------|------|------|\n| 自动托管 | `defaultAutoManaged`、`autoDiscoveryOnAdd` | 开启 |\n| 可信 advisor | 一等官方 API、本地 Ollama/LM Studio/llama-server、shadow 准确率门槛 | 只 shadow |\n| 信任策略 | 未知来源处理、信任等级 tie-breaker 权重、来源混杂提醒 | 未知只观察 |\n| 质量保护 | `capabilityFloorEnabled`、`protectVisionChannels`、`protectLongContextChannels` | 开启 |\n| 成本优化 | `costOptimization.enabled`、最低置信度、省钱权重 | 开启但在质量下界后生效 |\n| RPM 发现 | header 优先、AIMD、maxAutoRPM、手动配置优先 | 开启 shadow,Phase 2 生效 |\n| 探测预算 | L2 每日预算、公益站降频、并发限制 | 保守 |\n\n管理面板只保存全局策略。单个渠道来源、订阅、key 倍率仍从渠道中心或订阅中心编辑,避免入口重复。\n\n本地模型设置:\n- 支持快速添加 Ollama、LM Studio、llama-server、OpenAI-compatible runtime。\n- 每个 runtime 可选择角色:`候选模型`、`路由辅助`、`两者都启用`。\n- advisor 选择器只列出 `OriginTier=first` 的官方上游和 `OriginTier=local` 的本地运行时。\n- 官方 API/token plan 作为 advisor 时需要展示预计额外 token 成本;本地 runtime 作为 advisor 时展示本机延迟和 tokens/sec。\n- 中转站、公益站、未知来源在 UI 中显示为“不可用于隐私判定”,但仍可作为候选执行上游。\n- 路由辅助必须先进入 shadow 模式,展示与真实路由结果的差异、误判样例和平均耗时。\n- advisor 超时、不可用或准确率低于门槛时自动关闭真实影响,只保留诊断展示。\n\n### 8.7 驾驶舱\n\n驾驶舱是运营总览,不替代渠道中心。它需要回答四个问题:\n\n```text\n1. 当前请求是否健康:成功率、断流、限流、死亡渠道数\n2. 当前是否在省钱:实际有效成本、节省估算、低价 endpoint 命中率\n3. 当前是否有质量风险:主代理是否降级、vision/long-context 是否缺可用渠道\n4. 当前是否需要人工:余额低、来源混杂、成本配置缺失、RPM 未发现\n```\n\n驾驶舱卡片:\n- **来源分布**:一等/二等/三等/本地/未知来源数量、流量占比、成功率对比。\n- **实时最佳上游**:按任务类型展示当前胜出的 endpoint,说明胜出原因:质量、稳定、低成本、低延迟。\n- **低信任高表现提醒**:公益/中转在最近 15m 表现优于官方来源时展示,但标注这是短期运行质量,不改变信任等级。\n- **活跃试用**:列出 `ManualRoutingIntent`,显示命中请求数、预算剩余、成功率和 fallback 次数。\n- **本地模型与可信 advisor**:展示本地 candidate 命中量、估算节省、advisor shadow 准确率、额外判定成本和误判样例。\n- **成本节省**:按模型和任务类型展示 `official baseline` vs `effective actual`。\n- **人工待办**:余额低、倍率缺失、来源未知、endpoint 能力不一致、自动 RPM 置信度低。\n\n驾驶舱所有「修复」动作跳转到对应页面:\n- 余额/倍率 → 订阅中心\n- endpoint 健康/能力 → 渠道中心\n- 全局策略 → 管理面板\n- 调度解释 → SmartRouter dry-run\n\n---\n\n## 9. 配置设计\n\n### 9.1 全局智能路由配置\n\n在 `config.json` 新增顶层字段:\n\n```json\n{\n \"smartRouting\": {\n \"enabled\": true,\n \"mode\": \"auto\",\n \"killSwitch\": false,\n\n \"defaultAutoManaged\": true,\n \"autoDiscoveryOnAdd\": true,\n\n \"subagentUseCheapPool\": true,\n \"unknownChannelPolicy\": \"observe\",\n \"premiumFallbackForSubagent\": false,\n \"protectVisionChannels\": true,\n \"protectLongContextChannels\": true,\n\n \"healthCheck\": {\n \"enabled\": true,\n \"passiveSignalsOnly\": false,\n \"l2ProbeEnabled\": true,\n \"l2ProbeIntervalMinutes\": 120,\n \"l2ProbeMaxPerDay\": 12,\n \"deadProbeIntervalHours\": 6,\n \"deadConfidenceThreshold\": 0.80,\n \"autoExcludeDead\": true\n },\n\n \"fastDecay\": {\n \"enabled\": true,\n \"applyToCostTiers\": [\"free\", \"cheap\"],\n \"applyToPoolTags\": [\"temp\"],\n \"recoveryRate\": 0.15,\n \"decayBase\": 0.85,\n \"streamBreakDecayBase\": 0.70\n },\n\n \"rateLimitDiscovery\": {\n \"enabled\": true,\n \"applyOnlyWhenUnset\": true,\n \"preferHeaders\": true,\n \"passiveAimdEnabled\": true,\n \"minRpm\": 1,\n \"maxAutoRpm\": 120,\n \"maxAutoTpm\": 200000,\n \"maxAutoRpd\": 5000,\n \"maxAutoConcurrent\": 8,\n \"confidenceThreshold\": 0.6,\n \"increaseIntervalMinutes\": 10,\n \"increaseStepPercent\": 10,\n \"decreaseOn429Percent\": 50\n },\n\n \"modelMapping\": {\n \"autoResolve\": true,\n \"capabilityFloorEnabled\": true,\n \"echoMappedModel\": true,\n \"forbidChainMapping\": true\n },\n\n \"costOptimization\": {\n \"enabled\": true,\n \"applyAfterQualityFloor\": true,\n \"requireCostConfidence\": 0.6,\n \"includeCachePricing\": true,\n \"includeImageUnitPricing\": true,\n \"includeEmbeddingPricing\": true,\n \"currency\": \"USD\",\n \"exchangeRateSource\": \"manual\",\n \"preferLowerEffectiveCost\": true,\n \"supervisorSavingsWeight\": 0.5,\n \"workerSavingsWeight\": 3,\n \"providerTimePricing\": {\n \"deepseek\": {\n \"effectiveFrom\": \"2026-07-20T00:00:00+08:00\",\n \"timeZone\": \"Asia/Shanghai\",\n \"peakMultiplier\": 2,\n \"peakWindows\": [\n { \"start\": \"09:00\", \"end\": \"12:00\" },\n { \"start\": \"14:00\", \"end\": \"18:00\" }\n ]\n }\n }\n },\n\n \"originPolicy\": {\n \"unknownOriginPolicy\": \"observe\",\n \"preferHigherOriginTierAsTieBreaker\": true,\n \"originTieBreakerWeight\": 0.2,\n \"showLowerTierOutperforming\": true,\n \"warnMixedOriginChannel\": true\n },\n\n \"modelFamilyPreference\": {\n \"enabled\": true,\n \"weight\": 0.2,\n \"globalOrder\": [\"claude\", \"openai\", \"deepseek\", \"gemini\", \"qwen\", \"kimi\", \"glm\"],\n \"perTaskClass\": {\n \"supervisor\": [\"claude\", \"openai\"],\n \"worker\": [\"openai\", \"deepseek\", \"claude\", \"qwen\", \"kimi\", \"glm\"],\n \"lightweight\": [\"openai\", \"deepseek\", \"qwen\", \"gemini\", \"claude\"],\n \"vision\": [\"claude\", \"openai\", \"gemini\"],\n \"long_context\": [\"claude\", \"openai\", \"deepseek\", \"qwen\"],\n \"image_generation\": [\"openai\", \"agnes\", \"volcengine\"],\n \"embedding\": [\"openai\", \"qwen\"]\n }\n },\n\n \"costPreference\": {\n \"mode\": \"balanced\",\n \"perTaskClass\": {\n \"supervisor\": \"quality_first\",\n \"worker\": \"cost_first\",\n \"lightweight\": \"cost_first\"\n }\n },\n\n \"taskDomainStrength\": {\n \"enabled\": true,\n \"weight\": 0.5,\n \"seedMatrixOverrides\": {\n \"openai/gpt-5.x\": { \"aesthetics_ui\": 0.6, \"code_review\": 0.9 }\n }\n },\n\n \"reasoningEffort\": {\n \"enabled\": true,\n \"expandVariants\": true,\n \"perTaskClass\": {\n \"supervisor\": [\"high\", \"max\"],\n \"worker\": [\"medium\"],\n \"lightweight\": [\"off\", \"minimal\"],\n \"long_context\": [\"medium\", \"high\"]\n },\n \"respectClientThinking\": true\n },\n\n \"manualIntent\": {\n \"enabled\": true,\n \"defaultTtlMinutes\": 120,\n \"maxTtlHours\": 24,\n \"defaultMaxRequests\": 100,\n \"maxTrafficPercentForThirdTier\": 25,\n \"requireConfirmForSupervisor\": true,\n \"fallbackOnFailureDefault\": true\n },\n\n \"trustedRoutingAdvisor\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowedAdvisorOriginTiers\": [\"first\", \"local\"],\n \"forbidAdvisorOnRelayOrCommunity\": true,\n \"advisorRuntimeUid\": \"\",\n \"advisorChannelUid\": \"\",\n \"advisorTimeoutMs\": 1200,\n \"maxAdvisorPromptTokens\": 1200,\n \"minAdvisorConfidence\": 0.85,\n \"minShadowSamples\": 500,\n \"minShadowAccuracy\": 0.90,\n \"maxCriticalMisrouteRate\": 0.01,\n \"maxFalseDemotionRate\": 0.03,\n \"promotionMode\": \"manual\",\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"redactSensitiveMetadata\": true,\n \"recordOnlyHashedPrompt\": true,\n \"retainDecisionRecordsDays\": 7,\n \"autoRollbackOnSloRegression\": true,\n \"failOpenOnAdvisorError\": true\n },\n\n \"localModelRouting\": {\n \"enabled\": true,\n \"mode\": \"shadow\",\n \"allowLocalForTaskClasses\": [\"lightweight\", \"worker\"],\n \"neverDemoteTaskClasses\": [\"supervisor\", \"vision\", \"long_context\"],\n \"forbidAutoDecomposeAndMerge\": true,\n \"maxLocalCandidateLatencyMs\": 3000,\n \"maxLocalConcurrent\": 2,\n \"maxLocalPromptTokens\": 32000,\n \"remoteLocalRuntimeAllowed\": false,\n \"requireLocalRuntimeAuth\": true\n },\n\n \"taskStrategies\": {\n \"supervisor\": {\n \"preferQuality\": [\"high\", \"premium\"],\n \"requireStability\": [\"stable\", \"normal\"],\n \"excludeTags\": [\"unstable\", \"free\"]\n },\n \"worker\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"excludeQuality\": [\"premium\"]\n },\n \"image_generation\": {\n \"preferHealth\": [\"healthy\"],\n \"preferCost\": [\"cheap\", \"normal\"],\n \"requireNativeImagesEndpoint\": true,\n \"forbidChatToImageConversion\": true\n },\n \"embedding\": {\n \"preferCost\": [\"free\", \"cheap\"],\n \"preferSpeed\": [\"fast\"],\n \"requireNativeEmbeddingsEndpoint\": true,\n \"preserveEmbeddingDimension\": true\n }\n }\n }\n}\n```\n\n`providerTimePricing.effectiveFrom` 使用 RFC3339;修改该值即可调整激活时间,设为空字符串则停用对应规则。\n\n### 9.2 渠道级配置\n\n现有 `UpstreamConfig` 新增字段:\n\n```go\ntype UpstreamConfig struct {\n // ... 现有字段 ...\n\n // ── 稳定身份 ──\n ChannelUID string `json:\"channelUid,omitempty\"` // 渠道稳定 ID,ConfigManager 负责补齐旧配置\n SubscriptionUID string `json:\"subscriptionUid,omitempty\"` // 关联订阅/套餐,由订阅中心维护\n\n // ── 自动托管 ──\n AutoManaged bool `json:\"autoManaged,omitempty\"` // 启用自动托管\n AutoManagedAt *time.Time `json:\"autoManagedAt,omitempty\"` // 开始托管时间\n OriginType string `json:\"originType,omitempty\"` // official_api/official_token_plan/relay/community/local_runtime/unknown\n OriginTier string `json:\"originTier,omitempty\"` // first/second/third/local/unknown,信任/隐私等级\n BillingMode string `json:\"billingMode,omitempty\"` // official_api/token_plan/prepaid_credit/shared_free/unknown\n CostHint string `json:\"costHint,omitempty\"` // 用户成本提示:free/cheap/normal/expensive\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"` // channel 默认分组倍率,key 可覆盖\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"` // channel 默认充值倍率,key 可覆盖\n QualityHint string `json:\"qualityHint,omitempty\"` // 用户质量提示(override 自动推导)\n PoolTag string `json:\"poolTag,omitempty\"` // 池标签:temp/regular/premium\n RoutingPriority string `json:\"routingPriority,omitempty\"` // 路由优先级 hint\n}\n```\n\n**用户覆盖规则**:\n- 用户手动设置的字段(QualityHint/CostHint/PoolTag)优先级高于自动推导\n- 但自动推导的运行时指标(健康状态/熔断)始终生效,不受 override 影响\n- 用户可通过 `autoManaged: false` 退出自动托管,回到手动模式\n\n### 9.3 Key 级成本倍率配置\n\n用户添加 `baseUrls[]` 和 `apiKeys[]` 后,必须能给每个 key 标注成本倍率。成本倍率属于 key 级语义,因为同一 baseURL 下不同 key 可能属于不同分组、租户或充值批次。\n\n扩展 `APIKeyConfig`:\n\n```go\ntype APIKeyConfig struct {\n // ... 现有字段 ...\n\n // 成本分组名称,仅用于 UI 展示和批量编辑;不要与 QuotaGroup 混用。\n CostGroup string `json:\"costGroup,omitempty\"`\n\n // 分组倍率:key 为模型组或通配符,例如 \"*\"、\"claude-opus\"、\"gpt-5\"、\"gemini\"。\n // 未命中时回退 channel.GroupMultipliers,再回退 1.0。\n GroupMultipliers map[string]float64 `json:\"groupMultipliers,omitempty\"`\n\n // 充值倍率:1.0=无折扣;2.0=付 1 得 2,真实成本减半。\n // 未设置时回退 channel.RechargeMultiplier,再回退 1.0。\n RechargeMultiplier float64 `json:\"rechargeMultiplier,omitempty\"`\n\n // 可选:用户直接给出的成本备注,例如 \"8折企业组/双倍充值\"。\n CostNote string `json:\"costNote,omitempty\"`\n}\n```\n\n配置示例:\n\n```json\n{\n \"baseUrls\": [\"https://proxy-a.example/v1\", \"https://proxy-b.example/v1\"],\n \"apiKeys\": [\"sk-key-a\", \"sk-key-b\"],\n \"apiKeyConfigs\": [\n {\n \"key\": \"sk-key-a\",\n \"name\": \"A 组\",\n \"costGroup\": \"vip-a\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.2 },\n \"rechargeMultiplier\": 1.0\n },\n {\n \"key\": \"sk-key-b\",\n \"name\": \"充值活动组\",\n \"costGroup\": \"promo-b\",\n \"groupMultipliers\": { \"*\": 1.0, \"claude-opus\": 1.0 },\n \"rechargeMultiplier\": 2.0\n }\n ]\n}\n```\n\n解释:\n- `sk-key-a` 的 Opus 成本是官价 `1.2x`。\n- `sk-key-b` 的 Opus 成本是官价 `1.0 / 2.0 = 0.5x`。\n- 如果两者质量、健康、上下文和能力都满足,worker/lightweight 请求优先 `sk-key-b`。\n\n`baseUrls[] × apiKeys[]` 会生成多个 endpoint,但同一个 key 的成本配置默认应用到所有 baseURL。如果同一个 key 在不同 baseURL 上有不同价格,优先建议拆成两个 channel;不要在 MVP 中加入 baseURL 级成本 override,避免配置复杂度失控。\n\n### 9.4 endpoint 级自动结果的落点\n\n现有 `UpstreamConfig.ModelMapping`、`SupportedModels`、兼容开关都是 channel 级字段,而本方案的发现结果是 endpoint 级。落地规则:\n\n```text\n所有 endpoint 一致:\n 可安全写入 channel 级 ModelMapping / SupportedModels / 兼容开关\n\nendpoint 间不一致:\n 写入 ProfileStore 的 KeyEndpointProfile / ModelProfile\n UI 显示不一致警告\n 请求路径通过 EndpointAttemptPolicy 做 request-scoped 决策\n 不自动覆盖 channel 级 ModelMapping / SupportedModels\n```\n\n这避免一个 channel 内 key-A 支持 Claude 系列、key-B 支持 GPT 系列时,自动配置把 channel 级模型白名单写成错误并集。\n\n### 9.5 后台任务生命周期\n\nAutopilot 需要一个后台 worker,但必须是保守、可停止、可观测的:\n\n```text\n启动:\n - 加载 ProfileStore\n - 为旧 UpstreamConfig 补齐 ChannelUID\n - 同步当前配置 index 快照\n - 启动 L1 聚合、L2 探测队列、过期数据清理\n\n配置热重载:\n - 新 ChannelUID:创建 unknown profile\n - baseURL/apiKey/serviceType 变化:重算 endpoint 集合,新增 unknown,旧 endpoint 标记 orphaned\n - channel 重排:只更新 channel_id 展示快照,不迁移画像主键\n - channel 删除:标记 orphaned,延迟清理画像/证据/快照\n\n关闭:\n - 停止探测队列\n - flush 内存时间桶\n - 不中断正在处理的真实用户请求\n```\n\n多实例部署先按“best effort”处理:Phase 1 不做跨进程锁;Phase 2 若要自动 L2/L3 探测,需要在 SQLite 增加 `autopilot_jobs(locked_until, owner)`,避免多个进程重复烧 quota。\n\n---\n\n## 10. 分阶段落地计划\n\n### Phase 1:自动画像 + 健康诊断(MVP)\n\n**目标**:用户能看到渠道健康状态(精确到 endpoint 级),系统自动推导画像,但不改变真实调度结果。Phase 1 是 shadow/read-only 阶段。\n\n**范围**:\n- [ ] `ChannelUID` 稳定身份补齐 + 配置热重载同步\n- [ ] KeyEndpointProfile / ChannelProfile(聚合) / ModelProfile 数据模型 + SQLite 存储\n- [ ] SubscriptionProfile 数据模型 + 渠道 `SubscriptionUID` 关联(仅手动维护,不做余额自动抓取)\n- [ ] LocalModelRuntimeProfile 数据模型 + Ollama/LM Studio/llama-server 手动注册和探测\n- [ ] ManualRoutingIntent 数据模型 + 试用结果存储(shadow/read-only,不改变真实调度)\n- [ ] Profiler 画像推导(基于现有 MetricsManager + 模型注册表,L1 被动信号为主,endpoint 级粒度)\n- [ ] CostProfile shadow 计算(key 级分组倍率/充值倍率 → endpoint 有效成本,仅展示)\n- [ ] Images/Vectors 原生画像:仅记录健康、延迟、模型/维度/operation、成本和 RPM,不做 chat 协议转换\n- [ ] HealthAnalyzer 健康诊断(被动优先:L1 为主,endpoint 级判定 + channel 级聚合)\n- [ ] FastDecay shadow score(只展示/诊断,不参与调度)\n- [ ] RateLimitDiscoverer shadow profile(解析响应头和 429 模式,只展示建议 RPM,不改 limiter)\n- [ ] GroupChangeDetector 快照对比(仅手动刷新或已有 discovery/capability-test 后更新,不自动重探测)\n- [ ] 健康中心 API(`/api/health-center/*`,支持 endpoint 级展开)\n- [ ] 前端健康中心视图\n- [ ] 订阅中心基础视图(套餐/来源/倍率/绑定渠道)\n- [ ] 驾驶舱只读总览(来源分布、待办、成本 shadow 估算)\n- [ ] TrustedRoutingAdvisor shadow 评估(不影响真实调度)\n- [ ] 渠道卡片健康 badge 增强(显示 endpoint 级不一致警告)\n- [ ] 标签系统(官方/中转/公益、白嫖池/临时池/高智商稳定等)\n\n**不做的事**:\n- 不修改调度器核心链路\n- 不让 FastDecay / HealthState 影响真实调度\n- 不让 CostProfile 影响真实调度\n- 不让自动发现的 RPM 影响真实 limiter\n- 不自动触发 L2/L3 探测\n- 不自动写入 modelMapping\n- 不做模型自动映射\n\n**预估工期**:2-3 周\n\n### Phase 2:自动发现 + 智能调度\n\n**目标**:添加渠道时自动发现,调度时能按 channel + endpoint 画像选择,但仍不做动态模型自动映射。\n\n**范围**:\n- [ ] `autoManaged` 字段 + 快速添加流程\n- [ ] Discovery 自动触发(复用现有 channel_discovery 逻辑)\n- [ ] 自动写入一致的 channel 级 modelMapping / supportedModels / 兼容开关;不一致结果保留在 ProfileStore\n- [ ] SmartRouter + CandidateFilter 注入(保留 X-Channel/manual override/promotion 优先级)\n- [ ] EndpointAttemptPolicy 注入 `TryUpstreamWithAllKeys`,支持 endpoint 级 baseURL/key 排序\n- [ ] Cost-aware routing:满足质量/能力下界后,按 `estimatedRequestCost` 降低总费用\n- [ ] Origin-aware tie-breaker:仅在同质量/同健康/同成本档时使用信任等级,不把信任等级当质量\n- [ ] FastDecay 从 shadow score 切换为 endpoint 调度降权\n- [ ] RateLimitDiscoverer 对未显式设置 RPM 的 endpoint 应用 runtime limiter\n- [ ] L2 轻量探测 worker + 每日预算 + 探测队列\n- [ ] TaskClassifier + 七种任务策略(supervisor/worker/lightweight/vision/long_context/image_generation/embedding)\n- [ ] 智能路由诊断 API(dry-run)\n- [ ] ManualRoutingIntent 执行:model_trial/channel_trial/endpoint_trial/session_pin\n- [ ] TrustedRoutingAdvisor 只对 lightweight/worker 低风险请求提供真实路由 hint\n- [ ] 本地 candidate_model 可进入 endpoint 候选,但必须满足硬约束和 shadow 门槛\n- [ ] `/v1/images/*` 和 `/v1/embeddings` 接入 endpoint 排序;只选择原生上游,不做 chat 画图转换\n- [ ] 前端自动托管指示器 + 快速添加 UI\n\n**预估工期**:3-4 周\n\n### Phase 3:动态画像 + 自愈\n\n**目标**:画像实时更新,渠道自动恢复。\n\n**Phase 3A 状态(2026-07-09)**:低风险、不改真实路由语义的子集已完成并接入真实链路——`Manager.collectAll()` 新旧画像对比产出变更事件(`DetectProfileChanges`)、`ProfileChangelogStore`(环形内存 + SQLite,30 天机会性清理)、`EventHub` 内存 pub/sub、`GET /api/health-center/changelog`(历史)+ `GET /api/health-center/events`(WebSocket 实时推送)、`AutoDiscoveryRunner` 发布 `discovery_completed`/`auto_mapping_applied`、前端 `ProfileChangelogTimeline.vue` 接入健康中心页并支持断线自动重连。均为只读展示,未修改 `SmartRouter`/`EndpointAttemptPolicy` 调度逻辑。落地时顺带发现并修复一个真实缺口:浏览器原生 WebSocket 无法设自定义 header,为此给 `middleware.getAPIKey` 增加了 `Sec-WebSocket-Protocol` 鉴权回退(业界标准做法,仅新增一个 key 来源,不降低现有校验)。\n\n**Phase 3B(已全部完成,2026-07-09)**:模型自动映射、自动恢复探测、晋升/降级机制会直接改变真实调度决策,风险明显高于 3A,四个子项已分批全部完成。§12.2 P1.5 的 SQLite schema version / 版本化 migration / `OriginType`&`OriginTier` 旧配置 backfill / `killSwitch`+按 task class/channel disable **已于 2026-07-09 补强完成**(详见 §12.2 P1.5)。**Phase 3B-1(自动恢复探测补全)已于 2026-07-09 完成**:修复了 L2 探测子系统里此前从未打通的两个真实 bug——① `collectAll()` 每轮把新构造的画像整行 Upsert,会把 `ProbeWorker` 刚写入的 `LastProbeAt`/`ProbeSuccess`/`ConsecutiveProbeSuccess` 等字段清零,导致 6 小时探测冷却期形同虚设(修复:`carryForwardProbeFields` 在 Upsert 前搬运 Probe* 字段,不影响 L1 诊断字段的现有覆盖逻辑);② `extractRawAPIKey` 是返回 `KeyMask` 掩码值的占位 stub,探测请求实际带的是假 key(修复:新增 `APIKeyResolver` 回调 + `Manager.ResolveAPIKey`,通过遍历配置里对应渠道的 `APIKeys` 逐个计算 `KeyHashFromAPIKey` 比对来还原明文;resolver 未注入或未命中时 fail-open 跳过探测且不消耗预算)。同时补齐了 `degraded/limited → healthy` 的连续成功恢复路径(新增 `KeyEndpointProfile.ConsecutiveProbeSuccess` 计数器 + `HealthCheckConfig.ProbeRecoveryThreshold`,默认连续 2 次探测成功才恢复,避免单次探测噪声导致状态抖动;探测失败计数器清零)。**Phase 3B-3(StabilityTier 晋降级滞后)已于 2026-07-09 完成**(提交 664d3fff):在原本每轮从瞬时 1h 窗口重算的无记忆 `StabilityTier` 上加一层连续窗口滞后——新增 `KeyEndpointProfile.EffectiveStabilityTier`/`StabilityPendingTier`/`StabilityPendingStreak` 三字段 + 纯函数 `applyStabilityHysteresis`(对称阈值:连续 N 轮同值才切换生效档,`HealthCheckConfig.StabilityHysteresisWindows` 默认 2;首次画像直接采用,不等待阈值,避免新渠道卡在零值),`AggregateChannelProfile` 改读 `EffectiveStabilityTier`(空则回退 `StabilityTier`)。晋升/降级机制的数据模型仅限于 StabilityTier,不改变 QualityTier/SpeedTier/CostTier 语义。**Phase 3B-2(模型自动映射闭环)已于 2026-07-09 完成**(提交 0f02e988,随后修复一处 critical 集成 bug,提交 856372c4):新建 `ModelProfileStore`(SQLite,`model_profiles` 表)+ 扩展 `AutoDiscoveryRunner` 为每个发现模型写入 `ModelProfile`(仅 `AutoManaged` 渠道);新建 `ModelResolver` 实现 §5.4 五步逻辑(手动 `RedirectModel` 短路 → `GetModelProfiles` → `filterByCapabilityFloor` → `rankBySimilarity`);`scheduler` 包新增 `ModelSupportResolverFunc` hook(fail-open,未注入或返回不支持时严格回退到原 `ExplainModelSupport`);`endpoint_policy.go` 的 `resolveMappedModel`(此前是永不触发的死代码——`profile.ModelMapping` 从未被写入)现调用 `ModelResolver`;`handlers/common` 在 `RedirectModel` 之后应用 `MappedModel` 覆盖请求体,并按 `EchoMappedModel` 配置回显 `X-CCX-Mapped-Model`/`X-CCX-Original-Model`/`X-CCX-Mapping-Source` 响应头。三条件安全门控:`ModelMapping.AutoResolve==true`(**默认由死配置的 true 改为 false**)AND `RoutingMode ∈ {assist, auto}` AND 无 `KillSwitch`,三者任一不满足则所有新增代码路径行为与今天字节级一致;且仅 `AutoManaged` 渠道触发,手动配置渠道完全不受影响。对抗性复核发现并修复一个真实 critical bug:`scoreEndpointForKey` 用空 `channelUID` 计算 `EndpointUID` 并写入 `modelByUID`,而 `handlers` 层用真实 `channelUID` 查 `modelByUID`,两者哈希永不相等导致 `MappedModel` 在 key 级调度路径上完全查不到(提交 856372c4 修复:命中画像后改用 `profile.EndpointUID`,新增回归测试锁定该不变量)。至此 Phase 3B 四个子项(自动恢复探测、StabilityTier 滞后、模型自动映射、active filter 前置判定)全部完成;SLO regression 自动回滚仍是独立子系统,留待后续单独立项。\n\n**范围**:\n- [x] 运行时指标驱动画像实时更新(Phase 3A:`collectAll()` 每轮 diff + changelog + WebSocket 推送)\n- [x] 模型自动映射(ModelResolver + ModelSupportResolver + request-scoped mappedModel)—— Phase 3B-2(2026-07-09 完成,提交 0f02e988 + 856372c4)\n- [x] active model filter / context filter 支持自动映射前置判定 —— Phase 3B-2:`scheduler.ModelSupportResolverFunc` fail-open 接入 `select.go`\n- [x] 自动恢复探测(limited/dead → healthy)—— Phase 3B-1(2026-07-09 完成):`APIKeyResolver` + `carryForwardProbeFields` + `ConsecutiveProbeSuccess`/`ProbeRecoveryThreshold`\n- [x] 晋升/降级机制(连续成功→升级,连续失败→降级)—— Phase 3B-3(2026-07-09 完成,提交 664d3fff):`StabilityTier` 连续窗口滞后(`EffectiveStabilityTier`),暂不含 QualityTier/SpeedTier/CostTier\n- [x] WebSocket 推送画像变更事件(Phase 3A:`GET /api/health-center/events`)\n- [x] 前端画像变更历史/时间线(Phase 3A:`ProfileChangelogTimeline.vue`)\n\n**预估工期**:2-3 周(3A、3B-1、3B-2、3B-3 均已完成;advisor 与全局 auto 的 SLO 回滚均已补齐)\n\n> 状态校正(2026-07-17):上方 Phase 3B 历史说明中的“SLO regression 自动回滚待单独立项”已完成;advisor 回滚与全局 auto 上线闸门/自动降级是两个独立保护层。\n\n### Phase 4:高级特性\n\n**Phase 4 状态(2026-07-09,全部完成)**:8 项全部实现并接入真实链路,均遵循\"默认关闭 + 显式 opt-in\"安全守则。\n\n**范围**:\n- [x] 多维度标签系统扩展(用户自定义标签)——Item 1\n- [x] 成本报表:按用户/模型/渠道/key 统计真实有效成本——Item 2(新增 `proxyKeyMask` 身份贯穿链路:`middleware.GetProxyKeyMask` → `ChannelLog`/`RequestRecord` → SQLite `proxy_key_mask` 列,供 Item 4 复用)\n- [x] Trusted advisor SLO regression 自动回滚——Item 3(默认关闭;`SLORollbackConfig`,连续 degrading 窗口数默认 3,不做自动恢复以避免震荡)\n- [x] 渠道推荐:根据使用模式推荐新渠道——Item 4\n- [x] 批量渠道管理(导入/导出/模板)——Item 5\n- [x] 订阅中心 provider adapter:可选自动刷新余额/套餐状态——Item 6(默认关闭;仅 openai/anthropic/google 且显式填写 BillingAPIKey 的订阅生效,中转/公益渠道继续手动维护)\n- [x] 本地任务模板:用户显式配置可本地预处理/摘要的固定模板——Item 7\n- [x] A/B 测试:低比例统计抽样双发——Item 8(默认关闭,默认 1% 抽样 + 每小时 60 次硬预算;主请求路径不受影响,影子请求在主响应返回后异步发起)\n\n**关键实现说明**:\n- Item 3/6/8 均在 `AutopilotRoutingConfig` 新增独立配置块(`SLORollback`/`SubscriptionAutoRefresh`/`ABTest`),`Validate()` 统一做负值/越界兜底,保持与既有字段一致的防御模式。\n- Item 4 直接复用 Item 2 的 `proxyKeyMask` 身份标记,未重新发明身份识别机制。\n- **全局 auto 安全闸门(2026-07-17)**:请求终态按 15 分钟窗口无偏聚合,详细 trace 继续抽样;管理 API 只在 shadow/assist 已积累至少 500 个有效请求、24 小时连续观测且成功率/fallback/fail-open/p95 均达标时允许切换 `auto`。后台连续检测三个已完成 auto 窗口,相对最近七天安全模式基线持续恶化时,持久化降级到 `assist` 并记录 `autopilot_auto_safety_events`,不自动恢复。\n\n---\n\n## 11. 关键代码锚点\n\n### 11.1 需要扩展的文件\n\n| 文件 | 行号 | 扩展内容 |\n|------|------|---------|\n| `config/config.go` | `UpstreamConfig` / `APIKeyConfig` | 新增 ChannelUID/SubscriptionUID/OriginType/OriginTier/BillingMode/AutoManaged/CostHint/QualityHint/PoolTag,以及 key 级 GroupMultipliers/RechargeMultiplier |\n| `config/config_loader.go` | 配置加载/保存 | 为旧配置补齐 ChannelUID,并保证重排不改变 UID |\n| `scheduler/select.go` | `SelectChannelWithOptions` | 调整人工意图/显式控制与 CandidateFilter 顺序;支持 ModelSupportResolver 前置判断 |\n| `scheduler/recovery.go` | `SelectionOptions` | 必要时携带 protected channels / routing hints |\n| `handlers/common/multi_channel_failover.go` | `HandleMultiChannelFailover*` | 构建 RequestProfile,传入 SmartRouter plan |\n| `handlers/common/upstream_failover.go` | `TryUpstreamWithAllKeys` | 新增 WithEndpointAttemptPolicy,按 endpoint 画像排序 baseURL/key |\n| `keypool/keyconfig.go` | `CandidatesForModel` | 支持 endpoint policy 过滤/排序后的 key 候选 |\n| `ratelimit/hints.go` | `ApplyUpstreamHints` | 复用现有限流响应头解析;补充 limit/window 信号输出给 RateLimitDiscoverer |\n| `ratelimit/limiter.go` | `ChannelLimiter` | 支持从 discovered runtime config 更新 RPM,但不覆盖手动配置 |\n| `metrics/channel_metrics.go` | `MetricsManager` | 新增画像相关查询方法 |\n| `metrics/cost.go` | 成本估算 | 支持 CostProfile 的 effective multiplier、embedding token price 和 image unit price,不只使用模型官价 |\n| `providers/` | 本地 provider adapter | 增加 Ollama native、LM Studio、llama-server/OpenAI-compatible adapter |\n| `handlers/channel_discovery.go` | 全文 | Profiler 复用其探测逻辑 |\n| `handlers/capability_test_runner.go` | `runCapabilityTestJob` | 测试结果写入 ModelProfile |\n| `handlers/images_*` / `handlers/vectors_*` | 原生 images/vectors handlers | 构建 RequestProfile,保留 handler 硬约束,接入 SmartRouter endpoint 排序 |\n| `services/api.ts` / `api-types.ts` | 前端 API 类型 | 增加 SubscriptionProfile、origin 字段、dashboard 聚合字段 |\n| `router/index.ts` / `App.vue` | 前端导航 | 增加订阅中心、管理面板、驾驶舱入口 |\n\n### 11.2 需要新增的文件\n\n```text\nbackend-go/internal/autopilot/\n├── key_endpoint_profile.go # KeyEndpointProfile 类型(画像最小单元)\n├── channel_profile.go # ChannelProfile 聚合视图\n├── model_profile.go # ModelProfile 类型\n├── request_profile.go # RequestProfile + TaskClassifier\n├── profile_store.go # SQLite 持久化 + 内存缓存(endpoint 级)\n├── group_change_detector.go # Key 分组变更检测\n├── time_series.go # TimeBucketMetrics + 时序存储\n├── quality_trend_detector.go # QualityTrend 检测(忙闲/趋势/时段模式)\n├── profiler.go # 画像推导逻辑(L1 被动信号为主)\n├── health_analyzer.go # 健康诊断逻辑(被动优先 + 趋势信号 + L2 探测)\n├── cost_resolver.go # key 级分组倍率/充值倍率 → endpoint 有效成本\n├── subscription_profile.go # 订阅/套餐/来源画像\n├── local_model_runtime.go # Ollama/LM Studio/llama-server 运行时画像\n├── trusted_routing_advisor.go # 一等官方/本地模型结构化 RoutingHint 生成与 shadow 评估\n├── advisor_decision_store.go # AdvisorDecisionRecord、准入/回滚统计和隐私保留策略\n├── routing_trace.go # RoutingDecisionTrace、过滤原因和脱敏输出\n├── origin_policy.go # 信任等级解析、来源混杂检测、tie-breaker 策略\n├── manual_routing_intent.go # 模型/渠道/endpoint/session 短期试用意图\n├── manual_intent_store.go # TTL、预算、命中结果存储\n├── fast_decay.go # 白嫖/临时池快速衰减评分\n├── rate_limit_discovery.go # 上游 RPM/并发限制自动发现(header + passive AIMD)\n├── smart_router.go # SmartRouter + CandidateFilter 构建\n├── endpoint_policy.go # EndpointAttemptPolicy + endpoint 候选排序\n├── model_support_resolver.go # active model filter 前置的自动支持判断\n├── model_resolver.go # 模型自动映射 + CapabilityFloor 约束\n├── worker.go # L1 聚合 / L2 探测 / 清理后台任务\n└── handlers.go # API handlers\n\nbackend-go/internal/autopilot/\n├── autopilot_test.go # 画像推导测试\n├── health_analyzer_test.go # 健康诊断测试\n├── cost_resolver_test.go # 倍率继承、模型组匹配、有效成本计算测试\n├── native_media_routing_test.go # images/vectors 原生调度、禁止 chat 协议转换测试\n├── origin_policy_test.go # 信任等级不参与质量推导、仅 tie-breaker 测试\n├── subscription_profile_test.go # 套餐继承、渠道绑定、倍率回退测试\n├── local_model_runtime_test.go # 本地 runtime 探测、OpenAI-compatible adapter 测试\n├── trusted_routing_advisor_test.go # shadow 准确率、超时、不可降级约束测试\n├── advisor_privacy_test.go # AdvisorInput 白名单、低信任上游禁止、trace 脱敏测试\n├── routing_priority_test.go # kill switch、manual override、advisor、成本和信任 tie-breaker 顺序测试\n├── manual_routing_intent_test.go # TTL、预算、fallback、硬约束测试\n├── fast_decay_test.go # 快速衰减测试\n├── rate_limit_discovery_test.go # RPM header 解析与 AIMD 收敛测试\n├── group_change_test.go # 分组变更检测测试\n├── quality_trend_test.go # 趋势检测测试(忙闲模式识别)\n├── endpoint_policy_test.go # endpoint 排序与 fail-open/fail-closed 测试\n├── model_support_resolver_test.go # supportedModels 前置兼容测试\n└── smart_router_test.go # 路由策略测试\n\nfrontend/src/components/\n├── HealthCenter.vue # 健康中心主视图\n├── HealthCenterStats.vue # 统计卡片\n├── HealthChannelTable.vue # 渠道健康表格\n├── HealthChannelDetail.vue # 渠道详情侧栏\n├── SubscriptionsView.vue # 订阅中心\n├── SubscriptionPlanTable.vue # 套餐/来源列表\n├── AdminAutopilotPanel.vue # 管理面板中的 Autopilot 策略页\n├── OperationsCockpit.vue # 驾驶舱总览\n├── LocalModelSettings.vue # Ollama/LM Studio/llama-server 设置\n├── TrustedAdvisorTrace.vue # 一等官方/本地 advisor shadow trace 和误判样例\n├── ManualRoutingIntentDialog.vue # 模型/渠道试用创建面板\n├── ManualIntentSummary.vue # 活跃试用与结果摘要\n├── QuickAddChannel.vue # 快速添加渠道\n└── ChannelHealthBadge.vue # 健康状态 badge(增强现有)\n```\n\n### 11.3 与现有代码的接口契约\n\n| 接口 | 方向 | 说明 |\n|------|------|------|\n| `CandidateFilterFunc` | autopilot → scheduler | SmartRouter 用于 channel 级粗筛/重排 |\n| `EndpointAttemptPolicy` | autopilot → common failover | SmartRouter 用于 endpoint 级 baseURL/key 细选 |\n| `ModelSupportResolver` | autopilot → scheduler | 在 active model filter 阶段判断 autoManaged 渠道是否可 request-scoped 映射 |\n| `RateLimitDiscoverer` | autopilot → ratelimit | 未显式配置 RPM 时提供 endpoint/key/quota scope 的 runtime limiter 建议 |\n| `CostResolver` | autopilot → scheduler/common failover | 在满足质量和能力下界后,为 endpoint 排序提供 `estimatedRequestCost` |\n| `NativeEndpointPolicy` | autopilot → images/vectors handlers | 对原生生图和 embedding 请求只做 endpoint 排序,不改写请求协议或业务参数 |\n| `OriginPolicy` | autopilot → scheduler/frontend | 信任等级只用于隐私/治理展示和同分 tie-breaker,不参与 QualityTier |\n| `SubscriptionStore` | autopilot/frontend → config | 订阅中心维护套餐、余额、倍率和渠道绑定 |\n| `TrustedRoutingAdvisor` | autopilot → scheduler | 一等官方 API 或本地模型只产出 bounded RoutingHint,不直接决定最终路由 |\n| `LocalModelRuntimeStore` | autopilot/frontend → providers | 管理本地 runtime、模型能力、延迟和 advisor shadow 结果 |\n| `ManualRoutingIntentStore` | autopilot/frontend → scheduler | 存储短期人工意图、TTL、预算、试用结果 |\n| `MetricsManager.GetChannelAggregatedMetrics` | autopilot ← metrics | 画像推导读取运行时指标(入参是 baseURL + activeKeys + serviceType) |\n| `MetricsManager.GetTimeWindowStatsForKey` | autopilot ← metrics | **endpoint 级**指标查询 |\n| `config.ResolveUpstreamCapability` | autopilot ← config | 画像推导读取模型能力 |\n| `config.ResolveAgentModelProfile` | autopilot ← config | RequestProfile 推导质量需求 |\n| `channelDiscovery.*` | autopilot ← handlers | 复用探测逻辑(需抽取为可复用函数,支持单 endpoint 调用) |\n| `PersistenceStore` | autopilot → metrics | 新表复用同一 SQLite 连接 |\n\n---\n\n## 12. P0/P1/P2 落地契约与验收标准\n\n本节是实现前的阻断清单。P0 未完成时不得让 Autopilot 影响真实调度;P1 未完成时不得默认开启自动模式;P2 可作为体验增强分批交付。\n\n### 12.1 P0:必须先锁死\n\n#### P0.1 Trusted advisor 准入、退出与回滚\n\n`TrustedRoutingAdvisor` 必须先 shadow,再进入候选影响,最后才允许真实影响路由:\n\n```text\ndisabled\n → shadow:只记录 hint,不影响路由\n → candidate:满足样本/准确率门槛,仍需用户或配置显式允许\n → active:只对 lightweight/worker 低风险请求生效\n → rolled_back:SLO 恶化、误判超阈值、advisor 不可用或用户关闭\n```\n\n准入门槛:\n- `minShadowSamples >= 500`。\n- `minShadowAccuracy >= 0.90`。\n- `maxCriticalMisrouteRate <= 0.01`。\n- `maxFalseDemotionRate <= 0.03`。\n- `advisor p95 latency <= advisorTimeoutMs`,超时按 fail-open 处理。\n- 只允许 `OriginTier=first|local`;配置错误时直接降为 `disabled`。\n\n退出条件:\n- 任意 15 分钟窗口内 critical misroute 超阈值。\n- active 后成功率、fallback 率、p95 latency 或用户 override 率相对 shadow baseline 明显恶化。\n- advisor 连续超时、返回无效 JSON 或违反 `NeverDemote`。\n- 全局 `smartRouting.killSwitch=true`。\n\n```go\ntype AdvisorDecisionRecord struct {\n DecisionUID string `json:\"decisionUid\"`\n RequestUID string `json:\"requestUid,omitempty\"`\n\n AdvisorUID string `json:\"advisorUid\"`\n AdvisorOriginTier string `json:\"advisorOriginTier\"` // first | local\n Mode string `json:\"mode\"` // shadow | candidate | active\n\n TaskClass TaskClass `json:\"taskClass\"`\n PromptHash string `json:\"promptHash,omitempty\"` // 不存明文 prompt\n InputTokenBucket string `json:\"inputTokenBucket\"` // <1k | 1-10k | 10-50k | 50k+\n\n Hint TrustedRoutingHint `json:\"hint\"`\n DefaultPlanHash string `json:\"defaultPlanHash\"`\n Applied bool `json:\"applied\"`\n\n Outcome string `json:\"outcome\"` // matched | fallback | user_override | upstream_error | timeout\n MisrouteSeverity string `json:\"misrouteSeverity,omitempty\"` // none | minor | major | critical\n LatencyMs int64 `json:\"latencyMs\"`\n EstimatedAdvisorCost float64 `json:\"estimatedAdvisorCost,omitempty\"`\n CreatedAt time.Time `json:\"createdAt\"`\n}\n```\n\n记录保留:\n- `AdvisorDecisionRecord` 默认保留 7 天。\n- 只保存 hash、bucket、结构化特征和结果,不保存 prompt 明文、文件内容、Authorization 或 API Key。\n- 用户导出诊断时仍要二次脱敏。\n\n#### P0.2 Advisor 输入与隐私白名单\n\n`AdvisorInput` 必须是显式白名单,而不是把请求对象整体序列化:\n\n```go\ntype AdvisorInput struct {\n RequestKind string `json:\"requestKind\"` // messages | chat | responses | gemini\n Operation string `json:\"operation,omitempty\"`\n RequestedModel string `json:\"requestedModel,omitempty\"`\n AgentRole string `json:\"agentRole,omitempty\"`\n\n InputTokenBucket string `json:\"inputTokenBucket\"`\n HasImage bool `json:\"hasImage\"`\n NeedsToolUse bool `json:\"needsToolUse\"`\n NeedsReasoning bool `json:\"needsReasoning\"`\n NeedsLongContext bool `json:\"needsLongContext\"`\n\n RedactedTaskSummary string `json:\"redactedTaskSummary,omitempty\"`\n CandidateTaskClasses []TaskClass `json:\"candidateTaskClasses\"`\n}\n```\n\n隐私规则:\n- `RedactedTaskSummary` 只能由本地模型、一等官方 advisor 或确定性本地摘要器生成。\n- 禁止发送完整历史、文件全文、API Key、Authorization、自定义敏感 header、multipart 内容。\n- `second|third|unknown` 上游永远不能接收 `AdvisorInput`。\n- 如果摘要无法在可信执行面生成,advisor 直接跳过,SmartRouter 使用默认规则。\n\n#### P0.3 确定性 TaskClassifier\n\n`TaskClassifier` 必须先用确定性规则生成 task class。advisor 只能在既定枚举内提供 hint,不能发明新分类。\n\n硬规则:\n- `images` → `image_generation`。\n- `vectors` → `embedding`。\n- `HasImage && VisionNeed` → `vision`。\n- `ContextNeed > threshold` → `long_context`。\n- `Operation` 命中标题、分类、格式转换、`count_tokens` 等白名单,且无图片/工具/reasoning/长上下文 → `lightweight`。\n- `AgentRole=main` 或未知 → `supervisor`。\n- `AgentRole=subagent` → `worker`。\n- 不确定时升级到更保守分类,不降级到 lightweight。\n\n#### P0.4 最终调度优先级矩阵\n\n真实请求必须按以下顺序裁决:\n\n| 顺序 | 层级 | 规则 |\n|------|------|------|\n| 0 | 全局开关 | `killSwitch=true` 时只走现有调度,不读取 advisor hint |\n| 1 | 安全硬约束 | 鉴权、协议、上下文、vision/tool/reasoning、image/vector 原生能力、embedding 维度 |\n| 2 | 用户显式意图 | `ManualRoutingIntent`、`X-Channel`、session pin、manual override;仍不能绕过硬约束 |\n| 3 | 显式模型配置 | 用户 `modelMapping` / `supportedModels` 优先,自动结果不得覆盖 |\n| 4 | 运行可用性 | 熔断、cooldown、手动限流、runtime RPM/TPM/RPD/concurrency limiter |\n| 5 | advisor hint | 只对允许 task class 生效;不能覆盖 `NeverDemote` 或用户显式意图 |\n| 6 | 质量与能力排序 | `CapabilityFloor`、`MinQualityTier`、HealthState、QualityTrend |\n| 7 | 成本排序 | 只在质量/能力下界之后使用 `estimatedRequestCost` |\n| 8 | 信任等级 tie-breaker | 只在同质量/同健康/同成本档使用,不代表服务质量 |\n| 9 | endpoint failover | `TryUpstreamWithAllKeys` 按 EndpointAttemptPolicy 细选 baseURL/key |\n\n#### P0.5 验收不变量\n\n实现必须覆盖以下测试:\n- 低信任上游不会收到 `AdvisorInput`、prompt 摘要或隐私 metadata。\n- `killSwitch=true` 时 Autopilot 不影响真实调度。\n- 手动 `RateLimitRPM` / key 级限流不会被自动发现覆盖。\n- 成本排序永远发生在能力、质量、上下文和协议硬约束之后。\n- `ManualRoutingIntent` 不能绕过鉴权、模型能力、embedding 维度、images operation 或上下文限制。\n- advisor 不能把 `supervisor`、`vision`、`long_context` 降级。\n- `/v1/images/*` 只走原生 images endpoint,不从 chat/messages/responses 自动转换。\n- `/v1/embeddings` 不跨模型猜测维度,不改写 input。\n- 路由 trace 不包含明文 API Key、Authorization、prompt 全文或 multipart 内容。\n\n### 12.2 P1:默认自动模式前必须完成\n\n#### P1.1 成本模型覆盖面\n\nCostResolver 必须区分:\n- 文本 input/output token。\n- prompt cache read/write 价格。\n- embedding token 价格。\n- image unit price:按 model、size、quality、operation、n 计算。\n- token plan / prepaid credit / shared free 的余额与倍率。\n- 币种换算:MVP 使用手动汇率;自动汇率更新属于 Phase 4。\n\n缺价格时:\n- `Confidence < requireCostConfidence`。\n- 只展示 shadow 估算,不参与强排序。\n- 不用 chat token 价格替代 image unit price 或 embedding price。\n\n#### P1.2 限流模型覆盖面\n\nRateLimitDiscoverer 必须同时表达:\n- RPM:requests per minute。\n- TPM:tokens per minute。\n- RPD:requests per day。\n- MaxConcurrent:并发连接/流式请求占用。\n- Retry-After / reset window / cooldown。\n\n应用规则:\n- 手动值永远优先。\n- header 明确值优先于 AIMD。\n- 无 header 时只保守收敛,不能主动压测。\n- 流式请求先降并发,再降 RPM/TPM,避免把长连接误判成请求速率过高。\n\n#### P1.3 本地 runtime 资源与安全边界\n\n本地运行时必须有资源保护:\n- 默认只允许 loopback/private network;远程 local runtime 需要显式开启和鉴权。\n- 禁止通过代理绕过 loopback/private 检查;需要防 DNS rebinding。\n- `maxLocalConcurrent`、`maxLocalPromptTokens`、`maxLocalCandidateLatencyMs` 必须生效。\n- 本地 candidate 超时或慢于阈值时 fail-open,不阻塞高优先级请求。\n- 不自动 unload/load 模型;模型生命周期只做探测和提示,避免破坏用户本地环境。\n\n#### P1.4 Routing trace schema\n\n所有真实或 dry-run 路由都输出结构化 trace:\n\n```go\ntype RoutingDecisionTrace struct {\n TraceUID string `json:\"traceUid\"`\n TaskClass TaskClass `json:\"taskClass\"`\n RequestKind string `json:\"requestKind\"`\n\n ManualIntentUID string `json:\"manualIntentUid,omitempty\"`\n AdvisorDecisionUID string `json:\"advisorDecisionUid,omitempty\"`\n\n CandidatesBefore int `json:\"candidatesBefore\"`\n CandidatesAfter int `json:\"candidatesAfter\"`\n FilterReasons map[string][]string `json:\"filterReasons,omitempty\"`\n SortReasons []string `json:\"sortReasons,omitempty\"`\n\n SelectedChannelUID string `json:\"selectedChannelUid,omitempty\"`\n SelectedMetricsKey string `json:\"selectedMetricsKey,omitempty\"`\n SelectedOriginTier string `json:\"selectedOriginTier,omitempty\"`\n EstimatedRequestCost float64 `json:\"estimatedRequestCost,omitempty\"`\n CostConfidence float64 `json:\"costConfidence,omitempty\"`\n\n FallbackUsed bool `json:\"fallbackUsed\"`\n PromptHash string `json:\"promptHash,omitempty\"`\n}\n```\n\ntrace 只记录解释性字段,不记录明文 prompt、密钥、敏感 header 或 multipart。\n\n#### P1.5 迁移与回滚\n\n落地必须包含:\n- ~~SQLite schema version 和幂等 migration。~~ **已完成**:`ensureSchemaVersion` 基于 `PRAGMA user_version`,当前 7 张表定版为 v1 基线;库版本高于代码版本时返回 error(fail-closed,不做任何写操作),交给下游 fail-open 分支接管。\n- ~~旧配置 backfill:补 `ChannelUID`、`OriginType`、`OriginTier` 时不改变原调度。~~ **已完成**:`ensureOriginBackfill` 只补 `\"unknown\"`,不做任何基于 URL/名称的猜测推断;已接线到 `Profiler.DeriveEndpointProfile`,`BreakTieByOriginTier` 现在能吃到真实数据(此前一直是 fallback 的 `unknown`)。\n- ~~profile 损坏或 migration 失败时 fail-open:禁用 Autopilot,保留现有调度。~~ **已确认满足并测试锁定**:`main.go` 现有结构本就是 fail-open(`NewProfileStore`/`NewManager` 出错只打日志、`autopilotManager` 保持 `nil`,下游全部有 `nil` 守卫),本次新增 `schema_migration_test.go` 用测试锁定这条不变量。\n- ~~全局 `killSwitch`、按 task class disable、按 channel disable。~~ **已完成**:新增 `AutopilotRoutingConfig.DisabledTaskClasses`/`DisabledChannelUIDs`;`CandidateFilterFor`/`executeFilter`/`BuildPlan` 三处一致接入,`smart_router_invariant_test.go` 覆盖\"task class 禁用后回退默认调度\"\"channel 禁用后不出现在候选里\"\"dry-run 与真实路径行为一致\"三条不变量。\n- active 后的 SLO regression 自动 rollback 到 shadow,并在驾驶舱显示原因。**部分完成**:`ReleaseController.EvaluateAndApplyRegression` 已实现三窗口回归检测和 `SafetyOverride` 降级机制;`RoutingModeActive` 已加入配置枚举和 `IsAutopilotActive`。\n\n#### P1.6 Trace v2 契约与灰度发布\n\n> 对应实施计划:`docs/superpowers/plans/2026-07-23-autopilot-trace-rollout.md`\n\n**Trace v2 核心变更:**\n\n- `RoutingDecisionTrace` 增加 `SchemaVersion`、`TraceRevision`、`RequestCorrelationId`、`ReleaseID`、`PolicyFingerprint`、`TargetMode`/`EffectiveMode`/`Cohort`、`SchedulerDecision`、`EndpointAttempts` 字段。\n- 新增 `TraceDetailV2`、`TraceSummary`、`SchedulerDecisionSummary`、`EndpointAttemptSummary` DTO;使用显式 DTO 落盘而非直接 JSON 化运行时结构。\n- `ComparisonStatus` 三态枚举(`matched`/`mismatched`/`uncompared`)替代裸 `bool`。\n- `SanitizeForPersistence` 与 `SanitizeForResponse` 双边界脱敏;`GenerateTraceUIDv2` 使用 `crypto/rand` 碰撞安全生成。\n- `RoutingReleaseSnapshot` 不可变发布快照,入口冻结后续只读。\n\n**SQLite v6 迁移:**\n\n- trace 表新增列:`schema_version`、`trace_revision`、`request_correlation_id`、`release_id`、`policy_fingerprint`、`persistence_class`、`details_json`。\n- 窗口表重建主键:增加 `release_id`/`policy_fingerprint`/`cohort` 维度,新增 `compared_count`/`matched_count`/`mismatch_count`/`uncompared_count` 全量比较计数。\n- safety event 表增加 `release_id`/`policy_fingerprint` 字段。\n\n**Autopilot 单一运行态(2026-07-28):**\n\n顶层 `off → shadow → assist → auto → active` 灰度状态机已经废止。系统启动后固定执行 Autopilot 自动路由:应用硬约束过滤并按评分重排,候选全部被过滤时 fail-open。旧配置中的 `mode` 值会在加载时统一归一化为 `auto`,不再提供模式切换 API 或管理界面。\n\n`AUTOPILOT_KILL_SWITCH` 与配置中的 `killSwitch` 是唯一的全局急停入口;启用后不注入 SmartRouter 和 EndpointAttemptPolicy,恢复调度器默认行为。管理端 dry-run 仍作为独立诊断能力保留,不属于运行模式。\n\n发布批次、策略指纹和 cohort 字段继续保留在 Trace 契约中,用于兼容历史数据及诊断聚合;`ReleaseController`、readiness 样本门槛、自动降级/恢复和灰度模式切换代码均已删除。\n\n**只读 Trace API:**\n\n| 接口 | 返回 | 约束 |\n|---|---|---|\n| `GET /api/autopilot/traces` | `TraceSummary[]` | 游标分页、release/cohort/mode 过滤、partial 标记 |\n| `GET /api/autopilot/traces/:traceUid` | `TraceDetailV2` | 404=未找到/已过期/未采样;503=存储不可用 |\n| `GET /api/autopilot/traces/stats` | `TraceStatsResponse` | 三态比较统计、Mode/TaskClass 分布 |\n\n### 12.3 P2:体验与运营增强\n\nP2 不阻塞核心落地,但应进入后续 backlog:\n- images/vectors 在渠道中心独立显示 operation、尺寸/质量、embedding 维度、原生端点健康。\n- 前端异常态:advisor 被隐私策略禁用、本地 runtime 慢/不可用、成本置信度低、来源混杂、RPM/TPM 未发现。\n- 导入/导出时保留订阅、倍率、信任等级和本地 runtime 绑定,但不导出密钥明文。\n- 管理面板增加“解释模式”:只开启 trace/dry-run,不改真实调度。\n- 运营报表按 task class 展示节省、fallback、advisor 命中、手动 override 和 SLO 变化。\n\n---\n\n## 13. 风险与缓解\n\n| 风险 | 影响 | 缓解 |\n|------|------|------|\n| ~~自动模型映射语义降级~~ | ~~用户以为用 opus 实际用 haiku~~ | **已缓解**:CapabilityFloor 硬约束 + 不满足则跳过渠道而非降级映射 + response header 回显真实模型 |\n| ~~健康诊断烧 quota~~ | ~~30-40 渠道主动探测成本高~~ | **已缓解**:L1 被动优先(零成本),L2 仅在数据不足时触发,每天总次数上限 `渠道数×12` |\n| ~~白嫖池状态抖动~~ | ~~渠道反复断流导致调度震荡~~ | **已缓解**:FastDecay 请求级即时衰减 + 成功快速回升 + 断流比普通失败衰减更快 |\n| ~~能力虚标误判~~ | ~~系统误关 vision/tool 标签~~ | **已缓解**:只做硬失败检测(HTTP 错误/解析失败),软质量问题留给人工 override |\n| channel index 漂移 | 重排/删除后画像串到其他渠道 | 新增 `ChannelUID` 作为画像主键;`channelId` 仅作当前 index 展示快照 |\n| endpoint 决策无法落到真实请求 | SmartRouter 只重排 channel,无法选择具体 key/baseURL | 新增 `EndpointAttemptPolicy` 注入 `TryUpstreamWithAllKeys`,在 failover 层过滤/排序 endpoint |\n| 自动映射被 supportedModels 前置过滤挡掉 | ModelResolver 没机会运行,autoManaged 渠道被提前剔除 | 新增 `ModelSupportResolver` 接管 active model filter 的支持判断 |\n| 信任等级被误当成服务质量 | 官方渠道被无条件优先,公益/中转短时高质量时无法被利用 | `OriginTier` 独立于 `QualityTier`;调度中只做同分 tie-breaker;UI 同时展示信任等级和实时质量 |\n| 订阅中心与渠道中心重复维护价格 | 同一倍率在多处编辑导致成本计算不一致 | `SubscriptionProfile` 是套餐/余额/倍率事实源;继承链固定为 subscription → channel → key |\n| 界面职责混乱 | 渠道中心、订阅中心、管理面板、驾驶舱都塞满配置项 | 明确四页职责:渠道中心管 endpoint,订阅中心管套餐,管理面板管策略,驾驶舱管运营总览 |\n| 人工试用变成永久偏置 | 用户临时测试的 fable-5 或公益站长期压过 Autopilot | `ManualRoutingIntent` 必须有 TTL、请求/成本预算、fallback;到期只生成建议,不自动写长期策略 |\n| 未知模型污染全局映射 | 测试新模型时把错误映射写入所有请求 | `model_trial` 只做 request-scoped 透传/映射;结果标记 `manual_trial`,用户显式保存后才进入 `modelMapping` |\n| 隐私内容被发给低信任上游做判定 | 中转站/公益站接触额外 prompt、系统提示或 metadata | routing advisor 只允许 `OriginTier=first|local`;second/third/unknown 只可作为候选执行上游,不可做隐私敏感 classifier/evaluator |\n| advisor 误判后持续影响真实调度 | 低风险任务被系统性降级,用户质量下降 | advisor/local-model 保留独立 shadow 门槛;全局 Kill Switch 可立即停止 Autopilot |\n| trace 或 advisor 记录泄露隐私 | prompt、文件内容或密钥进入 SQLite/API 响应 | `AdvisorInput` 白名单、prompt hash、短 TTL、导出二次脱敏;禁止记录 Authorization/API Key/multipart |\n| 成本倍率配置错误 | 用户看到的具体费用不准,调度可能选错 key | 倍率必须显式展示来源和公式;低置信度成本只做 tie-breaker;UI 提供按 key 的有效价格预览 |\n| 生图成本被误按 chat token 估算 | images 调度选错上游或显示错误节省 | image_generation 使用 image unit price;缺价格时只 shadow 展示,不用 chat token 价格替代 |\n| chat 画图被误转成 images 请求 | 用户原本想走对话模型,系统改写协议导致行为变化 | MVP 禁止 chat/messages/responses → images 自动转换;只有显式 `/v1/images/*` 请求进入 image_generation 调度 |\n| embedding 维度/格式被自动改写 | 向量库写入不兼容或召回质量异常 | vectors handler 的维度/格式是硬约束;Autopilot 只排序满足约束的 endpoint,不跨模型猜测维度 |\n| 为省钱牺牲质量 | supervisor/vision/long-context 被路由到低质量或能力不足 endpoint | 成本排序只在 `CapabilityFloor`、`MinQualityTier`、上下文和能力硬约束通过后执行 |\n| 自动 RPM 发现过于激进 | 误把免费/低配额上游打到 429 或封禁 | 只在未显式设置时启用;优先 header;无 header 用保守 AIMD;不主动压测;`maxAutoRPM` 封顶 |\n| 自动 RPM 覆盖用户意图 | 用户手动设置被 runtime 学习值覆盖 | 手动 channel/key `RateLimitRPM` 永远优先;自动值只展示或用于未设置场景的 runtime limiter |\n| TPM/RPD/并发限制缺失 | 只按 RPM 控制仍可能触发 token 限额或长连接拥塞 | RateLimitProfile 同时记录 RPM/TPM/RPD/MaxConcurrent;流式先降并发再降 RPM/TPM |\n| endpoint 级探测成本倍增 | 5 key × 3 baseURL = 15 endpoint,探测量是 channel 级的 15 倍 | endpoint 级探测轮转执行,不并发;L2 探测每日总量仍有上限;channel 级聚合结果缓存避免重复计算 |\n| Key 换分组后模型列表突变 | 调度时才发现模型不可用 | GroupChangeDetector 在 L2 探测和 model_not_found 时自动检测;分组变更立即标记 stale 并重探测 |\n| endpoint 间能力不一致导致配置污染 | 同一 channel 有的 endpoint 支持 vision 有的不支持,自动写 channel 级配置会误导调度 | 只有所有 endpoint 一致时才写 channel 级配置;不一致结果保留 ProfileStore 并通过 EndpointAttemptPolicy request-scoped 生效 |\n| SmartRouter 增加调度延迟 | 请求耗时增加 | 画像缓存在内存,CandidateFilter 只做内存操作(< 1ms);聚合视图预计算,非实时聚合 |\n| 与现有 X-Channel/manual override/promotion 冲突 | 用户显式选择被自动调度过滤 | 调整调度阶段或传入 ProtectedChannels;显式控制优先,SmartRouter 不过滤受保护渠道 |\n| Phase 1 无智能调度时画像价值不明显 | 用户感知弱 | 明确 Phase 1 为 shadow/read-only;健康中心 + dry-run 诊断提前展示启用后效果 |\n| 自动 modelMapping 覆盖用户手动配置 | 用户设置被意外覆盖 | 显式 modelMapping 始终优先;请求路径自动映射默认 request-scoped,不直接写配置 |\n| 被动信号在低流量渠道不足 | 新渠道/冷渠道无法诊断 | 低流量时自动降级为 L2 探测,探测频率随请求量动态调整 |\n| 上游忙闲时段质量差异大 | 闲时探测通过但忙时不可用 | TimeBucketMetrics 15 分钟桶 + QualityTrend 时段模式识别;忙时自动降档而非标记 dead;SmartRouter 在忙时倾向非低谷 endpoint |\n| 趋势检测误判(短期波动 vs 真实恶化) | 频繁触发重评估浪费资源 | degrading 需要同时满足 24h 和 7d 双基准下降才触发重评估;volatile 状态只调 FastDecay 系数,不触发重评估 |\n| 多实例重复探测 | 多进程同时 L2/L3 探测导致 quota 消耗翻倍 | Phase 1 不自动探测;Phase 2 引入 `autopilot_jobs(locked_until, owner)` 后再启用自动 worker |\n| 管理 API 误操作 | 批量删除/暂停影响生产渠道 | 新端点要求管理权限;批量默认仅支持 refresh/probe/pause,删除走现有确认流程 |\n| 本地 runtime 被当成远端代理入口 | SSRF、内网探测或远程未鉴权 runtime 泄露请求 | 默认只允许 loopback/private network;远程 local runtime 必须显式开启和鉴权;防代理绕过和 DNS rebinding |\n| migration 失败影响生产流量 | schema/backfill 错误导致调度不可用 | migration 幂等;失败时 fail-open 禁用 Autopilot;保留现有调度和 kill switch |\n"}}, "frontend/src/utils/unifiedChannels.test.ts": {"f6977b2d05d47b726706c52e0ff5e873888ad2a1": {"t": 1785747457.1802793, "n": 18872, "h": "6c3f77c4b7979cc6", "c": "import { describe, expect, it } from 'vitest'\n\nimport type { Channel, ChannelMetrics, ChannelRecentActivity, ChannelsResponse } from '@/services/api'\nimport { buildUnifiedChannelsData, buildUnifiedRecentActivity, buildUnifiedReorderPayloads, normalizeChannelStatus, PartialRouteOperationError, resolveChannelStatusMutationRoutes, resolveChannelRecoveryRoutes, routeHasOnlyDisabledKeys, summarizeSettledRouteOperations, withRouteKindMetrics, type LlmChannelKind } from './unifiedChannels'\n\nconst channel = (\n name: string,\n accountUid: string,\n index: number,\n apiKeys: string[],\n overrides: Partial = {},\n): Channel => ({\n name,\n accountUid,\n channelUid: `ch-${index}`,\n providerId: 'mimo',\n autoManaged: true,\n index,\n serviceType: name.endsWith('-claude') ? 'claude' : 'openai',\n baseUrl: 'https://example.com',\n apiKeys,\n ...overrides,\n})\n\nconst response = (channels: Channel[]): ChannelsResponse => ({ channels, current: -1 })\n\ndescribe('logical channel display status', () => {\n const groupedStatus = (...statuses: Array) => {\n const kinds: LlmChannelKind[] = ['messages', 'chat', 'responses', 'gemini']\n const data = Object.fromEntries(kinds.map(kind => [kind, response([])])) as Record\n statuses.forEach((status, index) => {\n const kind = kinds[index]\n data[kind] = response([channel(`kimi-${kind}`, 'acct-status', index, ['sk-status'], { status })])\n })\n return buildUnifiedChannelsData(data).channels[0]\n }\n\n it.each([\n [undefined, 'active'],\n ['', 'active'],\n ['healthy', 'active'],\n ['active', 'active'],\n ['suspended', 'suspended'],\n ['disabled', 'disabled'],\n ['error', 'error'],\n ['unknown', 'unknown'],\n ] as const)('统一状态 %s 归一化为 %s', (status, expected) => {\n expect(normalizeChannelStatus(status)).toBe(expected)\n expect(groupedStatus(status, status).status).toBe(expected)\n })\n\n it.each([\n ['active', 'suspended'],\n ['active', 'disabled'],\n ['suspended', 'disabled'],\n ['healthy', 'suspended'],\n [undefined, 'disabled'],\n ] as const)('混合状态 %s + %s 聚合为 partial', (left, right) => {\n expect(groupedStatus(left, right).status).toBe('partial')\n })\n\n it('Kimi 类 messages 暂停而 chat/responses 活跃时显示部分可用并保留物理状态', () => {\n const logicalChannel = groupedStatus('suspended', 'active', 'active')\n expect(logicalChannel.status).toBe('partial')\n expect(logicalChannel.protocolRoutes?.map(route => route.status)).toEqual([\n 'suspended',\n 'active',\n 'active',\n ])\n })\n})\n\ndescribe('channel status mutations', () => {\n const logicalChannel = (): Channel => ({\n ...channel('kimi-claude', 'acct-kimi', 0, ['sk-kimi'], { status: 'partial' }),\n protocolRoutes: [\n { kind: 'messages', index: 0, name: 'kimi-claude', serviceType: 'claude', status: 'suspended' },\n { kind: 'chat', index: 1, name: 'kimi-chat', serviceType: 'openai', status: 'active' },\n { kind: 'responses', index: 2, name: 'kimi-codex', serviceType: 'responses', status: 'active' },\n ],\n })\n\n it('状态更新只生成 transport status,不会把 partial 发送给 API', () => {\n expect(resolveChannelStatusMutationRoutes(logicalChannel(), 'suspended')).toEqual([\n { kind: 'messages', index: 0, status: 'suspended' },\n { kind: 'chat', index: 1, status: 'suspended' },\n { kind: 'responses', index: 2, status: 'suspended' },\n ])\n expect(() => resolveChannelStatusMutationRoutes(logicalChannel(), 'partial')).toThrow(\n 'Display-only channel status cannot be persisted: partial',\n )\n })\n\n it('暂停 active+disabled 的部分可用渠道时保留 disabled 路由', () => {\n const mixed = logicalChannel()\n mixed.protocolRoutes![0].status = 'active'\n mixed.protocolRoutes![1].status = 'disabled'\n expect(resolveChannelStatusMutationRoutes(mixed, 'suspended')).toEqual([\n { kind: 'messages', index: 0, status: 'suspended' },\n { kind: 'responses', index: 2, status: 'suspended' },\n ])\n })\n\n it('显式禁用逻辑渠道时 fan-out 到全部物理路由', () => {\n const mixed = logicalChannel()\n mixed.protocolRoutes![1].status = 'disabled'\n expect(resolveChannelStatusMutationRoutes(mixed, 'disabled')).toEqual([\n { kind: 'messages', index: 0, status: 'disabled' },\n { kind: 'chat', index: 1, status: 'disabled' },\n { kind: 'responses', index: 2, status: 'disabled' },\n ])\n })\n})\n\ndescribe('settled route operations', () => {\n it('保留部分成功与失败计数,供组件刷新后提示部分失败', () => {\n const summary = summarizeSettledRouteOperations([\n { status: 'fulfilled', value: 2 },\n { status: 'rejected', reason: new Error('chat failed') },\n { status: 'fulfilled', value: 1 },\n ])\n expect(summary).toMatchObject({ fulfilled: [2, 1], successCount: 2, failureCount: 1, totalCount: 3 })\n const error = new PartialRouteOperationError(summary)\n expect(error).toMatchObject({ successCount: 2, failureCount: 1, totalCount: 3 })\n expect(error.message).toContain('chat failed')\n })\n})\n\ndescribe('withRouteKindMetrics', () => {\n const metric = (routeKind?: Channel['routeKind']): ChannelMetrics => ({\n channelIndex: 1,\n routeKind,\n requestCount: 0,\n successCount: 0,\n failureCount: 0,\n successRate: 0,\n errorRate: 0,\n consecutiveFailures: 0,\n latency: 0,\n })\n\n it('routeKind 已存在时复用指标数组和对象引用', () => {\n const metrics = [metric('messages')]\n expect(withRouteKindMetrics('messages', metrics)).toBe(metrics)\n })\n\n it('仅为旧格式指标补充 routeKind', () => {\n const metrics = [metric()]\n const result = withRouteKindMetrics('chat', metrics)\n expect(result).not.toBe(metrics)\n expect(result[0]).toEqual({ ...metrics[0], routeKind: 'chat' })\n })\n})\n\ndescribe('buildUnifiedChannelsData account grouping', () => {\n it('优先按 accountUid 聚合多协议渠道,不依赖 Key 指纹', () => {\n const data: Record = {\n messages: response([channel('mimo-main-claude', 'acct-main', 0, ['sk-a'])]),\n chat: response([channel('mimo-main-chat', 'acct-main', 1, ['sk-a', 'sk-b'])]),\n responses: response([channel('mimo-main-codex', 'acct-main', 2, ['sk-b'])]),\n gemini: response([channel('mimo-main-gemini', 'acct-main', 3, ['sk-a'])]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].accountUid).toBe('acct-main')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses', 'gemini'])\n expect(result.channels[0].protocolRoutes?.map(item => item.status)).toEqual([undefined, undefined, undefined, undefined])\n expect(result.channels[0].protocolRoutes?.map(item => item.apiKeys)).toEqual([\n ['sk-a'],\n ['sk-a', 'sk-b'],\n ['sk-b'],\n ['sk-a'],\n ])\n })\n\n it('保留物理路由各自的 Key 配置与拉黑状态,避免共享 Key 误判', () => {\n const disabled = {\n key: 'sk-shared',\n reason: 'authentication_error',\n message: 'invalid only on messages',\n disabledAt: '2026-07-28T00:00:00Z',\n }\n const data: Record = {\n messages: response([channel('kimi-claude', 'acct-route-keys', 0, [], {\n status: 'active',\n disabledApiKeys: [disabled],\n apiKeyConfigs: [{ key: 'sk-shared', enabled: true }],\n })]),\n chat: response([channel('kimi-chat', 'acct-route-keys', 1, ['sk-shared'], {\n status: 'active',\n apiKeyConfigs: [{ key: 'sk-shared', enabled: true }],\n })]),\n responses: response([]),\n gemini: response([]),\n }\n\n const routes = buildUnifiedChannelsData(data).channels[0].protocolRoutes!\n expect(routes[0]).toMatchObject({\n kind: 'messages',\n apiKeys: [],\n apiKeyConfigs: [{ key: 'sk-shared', enabled: true }],\n disabledApiKeys: [disabled],\n })\n expect(routes[1]).toMatchObject({\n kind: 'chat',\n apiKeys: ['sk-shared'],\n apiKeyConfigs: [{ key: 'sk-shared', enabled: true }],\n disabledApiKeys: undefined,\n })\n expect(routeHasOnlyDisabledKeys(routes[0])).toBe(true)\n expect(routeHasOnlyDisabledKeys(routes[1])).toBe(false)\n })\n\n it('聚合账号合并各协议凭证并按全部路由计算状态', () => {\n const data: Record = {\n messages: response([channel('kimi-claude', 'acct-kimi', 0, [], {\n status: 'suspended',\n })]),\n chat: response([channel('kimi-chat', 'acct-kimi', 1, ['sk-kimi', 'sk-disabled'], {\n status: 'active',\n apiKeyConfigs: [{ key: 'sk-kimi', credentialUid: 'cred-kimi' }],\n disabledApiKeys: [{\n key: 'sk-disabled',\n reason: 'authentication_error',\n message: 'invalid key',\n disabledAt: '2026-07-28T00:00:00Z',\n }],\n })]),\n responses: response([]),\n gemini: response([]),\n }\n\n const [logicalChannel] = buildUnifiedChannelsData(data).channels\n expect(logicalChannel.apiKeys).toEqual(['sk-kimi', 'sk-disabled'])\n expect(logicalChannel.apiKeyConfigs).toEqual([\n { key: 'sk-kimi', credentialUid: 'cred-kimi' },\n ])\n expect(logicalChannel.disabledApiKeys).toEqual([{\n key: 'sk-disabled',\n reason: 'authentication_error',\n message: 'invalid key',\n disabledAt: '2026-07-28T00:00:00Z',\n }])\n expect(logicalChannel.status).toBe('partial')\n })\n\n it('保留各协议路由状态供聚合渠道恢复使用', () => {\n const data: Record = {\n messages: response([channel('mimo-main-claude', 'acct-main', 0, ['sk-a'], { status: 'suspended' })]),\n chat: response([channel('mimo-main-chat', 'acct-main', 1, ['sk-a'], { status: 'active' })]),\n responses: response([channel('mimo-main-codex', 'acct-main', 2, ['sk-a'], { status: 'disabled' })]),\n gemini: response([]),\n }\n\n const logicalChannel = buildUnifiedChannelsData(data).channels[0]\n expect(logicalChannel.protocolRoutes?.map(route => route.status)).toEqual([\n 'suspended',\n 'active',\n 'disabled',\n ])\n expect(resolveChannelRecoveryRoutes(logicalChannel)).toEqual([\n { kind: 'messages', index: 0, status: 'suspended' },\n { kind: 'chat', index: 1, status: 'active' },\n { kind: 'responses', index: 2, status: 'disabled' },\n ])\n })\n\n it('相同 provider 和名称下不同 accountUid 不应合并', () => {\n const data: Record = {\n messages: response([\n channel('mimo-main-claude', 'acct-a', 0, ['sk-a']),\n channel('mimo-main-claude', 'acct-b', 1, ['sk-b']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n\n expect(buildUnifiedChannelsData(data).channels).toHaveLength(2)\n })\n\n it('按 accountUid 聚合自定义自动托管的全部成功协议', () => {\n const custom = (name: string, index: number, serviceType: Channel['serviceType']): Channel =>\n channel(name, 'acct-fastaitoken', index, ['sk-fastaitoken'], { providerId: '', autoManaged: true, serviceType })\n const data: Record = {\n messages: response([{\n ...custom('fastaitoken-com-test-claude', 0, 'claude'),\n supportedModels: ['gpt-5.6-sol', 'gpt-5.6-terra'],\n }]),\n chat: response([{\n ...custom('fastaitoken-com-test-chat', 0, 'openai'),\n supportedModels: ['gpt-5.6-sol'],\n }]),\n responses: response([{\n ...custom('fastaitoken-com-test-codex', 0, 'responses'),\n supportedModels: ['codex-auto-review', 'gpt-5.6-sol'],\n }]),\n gemini: response([]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].name).toBe('fastaitoken-com-test')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT', 'CODEX'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses'])\n expect(Object.fromEntries(\n result.channels[0].protocolRoutes?.map(route => [route.kind, route.supportedModels]) ?? [],\n )).toEqual({\n messages: ['gpt-5.6-sol', 'gpt-5.6-terra'],\n chat: ['gpt-5.6-sol'],\n responses: ['codex-auto-review', 'gpt-5.6-sol'],\n })\n })\n\n it('协议标签只展示上游实际提供的 serviceType', () => {\n const data: Record = {\n messages: response([channel('volcengine-claude', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'claude',\n })]),\n chat: response([channel('volcengine-chat', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n responses: response([channel('volcengine-codex', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n gemini: response([channel('volcengine-gemini', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n }\n\n const [volcengine] = buildUnifiedChannelsData(data).channels\n expect(volcengine.protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(volcengine.protocolRoutes).toHaveLength(4)\n expect(volcengine.protocolRoutes?.every(route => route.supportedModels === undefined)).toBe(true)\n })\n\n it('新增单协议渠道置顶后保持多协议账号的既有相对顺序', () => {\n const data: Record = {\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '', priority: 0 }),\n channel('volcengine-claude', 'acct-volcengine', 1, ['sk-volcengine'], { providerId: 'volcengine', priority: 1 }),\n channel('mimo-claude', 'acct-mimo', 2, ['sk-mimo'], { priority: 2 }),\n ]),\n chat: response([\n channel('volcengine-chat', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-chat', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('desktop-deepseek-chat', 'acct-deepseek', 34, ['sk-deepseek'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n responses: response([\n channel('volcengine-codex', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-codex', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('aixoras-xanqfm', 'acct-aixoras', 2, ['sk-aixoras'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n gemini: response([\n channel('volcengine-gemini', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-gemini', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n ]),\n }\n\n const channels = buildUnifiedChannelsData(data).channels\n const sorted = [...channels].sort((a, b) => (a.priority ?? a.index) - (b.priority ?? b.index))\n\n expect(sorted.slice(0, 5).map(item => item.name)).toEqual([\n 'localhost-37zq4d',\n 'volcengine',\n 'mimo',\n 'desktop-deepseek-chat',\n 'aixoras-xanqfm',\n ])\n expect(channels.find(item => item.name === 'mimo')?.priority).toBe(1)\n })\n\n it('在列表头部插入渠道时保持既有逻辑渠道的展示 key 稳定', () => {\n const original: Record = {\n messages: response([\n channel('volcengine-claude', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine' }),\n channel('mimo-claude', 'acct-mimo', 1, ['sk-mimo']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n const withNewChannel: Record = {\n ...original,\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '' }),\n ...original.messages.channels,\n ]),\n }\n\n const originalMimo = buildUnifiedChannelsData(original).channels.find(item => item.name === 'mimo')\n const nextMimo = buildUnifiedChannelsData(withNewChannel).channels.find(item => item.name === 'mimo')\n\n expect(nextMimo?.displayKey).toBe(originalMimo?.displayKey)\n })\n\n it('聚合逻辑渠道全部协议的最近请求活动', () => {\n const data: Record = {\n messages: response([channel('mimo-claude', 'acct-mimo', 0, ['sk-mimo'])]),\n chat: response([channel('mimo-chat', 'acct-mimo', 1, ['sk-mimo'])]),\n responses: response([]),\n gemini: response([]),\n }\n const [logicalChannel] = buildUnifiedChannelsData(data).channels\n const activity = (\n channelIndex: number,\n requestCount: number,\n successCount: number,\n failureCount: number,\n ): ChannelRecentActivity => ({\n channelIndex,\n segments: {\n 4: { requestCount, successCount, failureCount, inputTokens: requestCount * 10, outputTokens: requestCount * 2 },\n },\n totalSegs: 150,\n rpm: requestCount / 15,\n tpm: requestCount * 2 / 15,\n })\n\n const [merged] = buildUnifiedRecentActivity([logicalChannel], {\n messages: [activity(0, 2, 2, 0)],\n chat: [activity(1, 3, 2, 1)],\n responses: [],\n gemini: [],\n })\n\n expect(merged.channelIndex).toBe(0)\n expect(merged.routeKind).toBe('messages')\n expect(merged.segments[4]).toEqual({\n requestCount: 5,\n successCount: 4,\n failureCount: 1,\n inputTokens: 50,\n outputTokens: 10,\n })\n expect(merged.rpm).toBeCloseTo(5 / 15)\n expect(merged.tpm).toBeCloseTo(10 / 15)\n })\n})\n\ndescribe('buildUnifiedReorderPayloads', () => {\n it('priority 使用统一列表全局位次而非各协议组内名次', () => {\n const data: Record = {\n messages: response([\n channel('a-claude', 'acct-a', 0, ['sk-a']),\n channel('b-claude', 'acct-b', 1, ['sk-b']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([channel('g-gemini', 'acct-g', 0, ['sk-g'], { serviceType: 'gemini' })]),\n }\n const unified = buildUnifiedChannelsData(data).channels\n const a = unified.find(c => c.protocolRoutes?.some(r => r.kind === 'messages' && r.index === 0))!\n const b = unified.find(c => c.protocolRoutes?.some(r => r.kind === 'messages' && r.index === 1))!\n const g = unified.find(c => c.protocolRoutes?.some(r => r.kind === 'gemini'))!\n\n // 模拟拖拽后的统一列表顺序:a, g, b\n const payloads = buildUnifiedReorderPayloads([a, g, b])\n\n // messages:a 位次 1,b 位次 3(中间隔着 gemini 渠道)\n expect(payloads.get('messages')).toEqual({ order: [0, 1], priorities: [1, 3] })\n // gemini:g 位次 2。若按组内名次编号会得到 1,刷新后 min(priority) 会把 g 提到最前\n expect(payloads.get('gemini')).toEqual({ order: [0], priorities: [2] })\n expect(payloads.has('chat')).toBe(false)\n })\n\n it('无 protocolRoutes 的渠道不产生载荷', () => {\n const payloads = buildUnifiedReorderPayloads([{ name: 'plain', index: 0 } as Channel])\n expect(payloads.size).toBe(0)\n })\n})\n"}, "784041ebc2a680321b17c44db2f89e95b9470dbe": {"t": 1785759314.3593853, "n": 19736, "h": "0a2cbf91b48e4522", "c": "import { describe, expect, it } from 'vitest'\n\nimport type { Channel, ChannelMetrics, ChannelRecentActivity, ChannelsResponse } from '@/services/api'\nimport { buildUnifiedChannelsData, buildUnifiedRecentActivity, buildUnifiedReorderPayloads, normalizeChannelStatus, PartialRouteOperationError, resolveChannelStatusMutationRoutes, resolveChannelRecoveryRoutes, routeHasOnlyDisabledKeys, summarizeSettledRouteOperations, withRouteKindMetrics, type LlmChannelKind } from './unifiedChannels'\n\nconst channel = (\n name: string,\n accountUid: string,\n index: number,\n apiKeys: string[],\n overrides: Partial = {},\n): Channel => ({\n name,\n accountUid,\n channelUid: `ch-${index}`,\n providerId: 'mimo',\n autoManaged: true,\n index,\n serviceType: name.endsWith('-claude') ? 'claude' : 'openai',\n baseUrl: 'https://example.com',\n apiKeys,\n ...overrides,\n})\n\nconst response = (channels: Channel[]): ChannelsResponse => ({ channels, current: -1 })\n\ndescribe('logical channel display status', () => {\n const groupedStatus = (...statuses: Array) => {\n const kinds: LlmChannelKind[] = ['messages', 'chat', 'responses', 'gemini']\n const data = Object.fromEntries(kinds.map(kind => [kind, response([])])) as Record\n statuses.forEach((status, index) => {\n const kind = kinds[index]\n data[kind] = response([channel(`kimi-${kind}`, 'acct-status', index, ['sk-status'], { status })])\n })\n return buildUnifiedChannelsData(data).channels[0]\n }\n\n it.each([\n [undefined, 'active'],\n ['', 'active'],\n ['healthy', 'active'],\n ['active', 'active'],\n ['suspended', 'suspended'],\n ['disabled', 'disabled'],\n ['error', 'error'],\n ['unknown', 'unknown'],\n ] as const)('统一状态 %s 归一化为 %s', (status, expected) => {\n expect(normalizeChannelStatus(status)).toBe(expected)\n expect(groupedStatus(status, status).status).toBe(expected)\n })\n\n it.each([\n ['active', 'suspended'],\n ['active', 'disabled'],\n ['suspended', 'disabled'],\n ['healthy', 'suspended'],\n [undefined, 'disabled'],\n ] as const)('混合状态 %s + %s 聚合为 partial', (left, right) => {\n expect(groupedStatus(left, right).status).toBe('partial')\n })\n\n it('Kimi 类 messages 暂停而 chat/responses 活跃时显示部分可用并保留物理状态', () => {\n const logicalChannel = groupedStatus('suspended', 'active', 'active')\n expect(logicalChannel.status).toBe('partial')\n expect(logicalChannel.protocolRoutes?.map(route => route.status)).toEqual([\n 'suspended',\n 'active',\n 'active',\n ])\n })\n})\n\ndescribe('channel status mutations', () => {\n const logicalChannel = (): Channel => ({\n ...channel('kimi-claude', 'acct-kimi', 0, ['sk-kimi'], { status: 'partial' }),\n protocolRoutes: [\n { kind: 'messages', index: 0, name: 'kimi-claude', serviceType: 'claude', status: 'suspended' },\n { kind: 'chat', index: 1, name: 'kimi-chat', serviceType: 'openai', status: 'active' },\n { kind: 'responses', index: 2, name: 'kimi-codex', serviceType: 'responses', status: 'active' },\n ],\n })\n\n it('状态更新只生成 transport status,不会把 partial 发送给 API', () => {\n expect(resolveChannelStatusMutationRoutes(logicalChannel(), 'suspended')).toEqual([\n { kind: 'messages', index: 0, status: 'suspended' },\n { kind: 'chat', index: 1, status: 'suspended' },\n { kind: 'responses', index: 2, status: 'suspended' },\n ])\n expect(() => resolveChannelStatusMutationRoutes(logicalChannel(), 'partial')).toThrow(\n 'Display-only channel status cannot be persisted: partial',\n )\n })\n\n it('暂停 active+disabled 的部分可用渠道时保留 disabled 路由', () => {\n const mixed = logicalChannel()\n mixed.protocolRoutes![0].status = 'active'\n mixed.protocolRoutes![1].status = 'disabled'\n expect(resolveChannelStatusMutationRoutes(mixed, 'suspended')).toEqual([\n { kind: 'messages', index: 0, status: 'suspended' },\n { kind: 'responses', index: 2, status: 'suspended' },\n ])\n })\n\n it('显式禁用逻辑渠道时 fan-out 到全部物理路由', () => {\n const mixed = logicalChannel()\n mixed.protocolRoutes![1].status = 'disabled'\n expect(resolveChannelStatusMutationRoutes(mixed, 'disabled')).toEqual([\n { kind: 'messages', index: 0, status: 'disabled' },\n { kind: 'chat', index: 1, status: 'disabled' },\n { kind: 'responses', index: 2, status: 'disabled' },\n ])\n })\n})\n\ndescribe('settled route operations', () => {\n it('保留部分成功与失败计数,供组件刷新后提示部分失败', () => {\n const summary = summarizeSettledRouteOperations([\n { status: 'fulfilled', value: 2 },\n { status: 'rejected', reason: new Error('chat failed') },\n { status: 'fulfilled', value: 1 },\n ])\n expect(summary).toMatchObject({ fulfilled: [2, 1], successCount: 2, failureCount: 1, totalCount: 3 })\n const error = new PartialRouteOperationError(summary)\n expect(error).toMatchObject({ successCount: 2, failureCount: 1, totalCount: 3 })\n expect(error.message).toContain('chat failed')\n })\n})\n\ndescribe('withRouteKindMetrics', () => {\n const metric = (routeKind?: Channel['routeKind']): ChannelMetrics => ({\n channelIndex: 1,\n routeKind,\n requestCount: 0,\n successCount: 0,\n failureCount: 0,\n successRate: 0,\n errorRate: 0,\n consecutiveFailures: 0,\n latency: 0,\n })\n\n it('routeKind 已存在时复用指标数组和对象引用', () => {\n const metrics = [metric('messages')]\n expect(withRouteKindMetrics('messages', metrics)).toBe(metrics)\n })\n\n it('仅为旧格式指标补充 routeKind', () => {\n const metrics = [metric()]\n const result = withRouteKindMetrics('chat', metrics)\n expect(result).not.toBe(metrics)\n expect(result[0]).toEqual({ ...metrics[0], routeKind: 'chat' })\n })\n})\n\ndescribe('buildUnifiedChannelsData account grouping', () => {\n it('优先按 accountUid 聚合多协议渠道,不依赖 Key 指纹', () => {\n const data: Record = {\n messages: response([channel('mimo-main-claude', 'acct-main', 0, ['sk-a'])]),\n chat: response([channel('mimo-main-chat', 'acct-main', 1, ['sk-a', 'sk-b'])]),\n responses: response([channel('mimo-main-codex', 'acct-main', 2, ['sk-b'])]),\n gemini: response([channel('mimo-main-gemini', 'acct-main', 3, ['sk-a'])]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].accountUid).toBe('acct-main')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses', 'gemini'])\n expect(result.channels[0].protocolRoutes?.map(item => item.status)).toEqual([undefined, undefined, undefined, undefined])\n expect(result.channels[0].protocolRoutes?.map(item => item.apiKeys)).toEqual([\n ['sk-a'],\n ['sk-a', 'sk-b'],\n ['sk-b'],\n ['sk-a'],\n ])\n })\n\n it('保留物理路由各自的 Key 配置与拉黑状态,避免共享 Key 误判', () => {\n const disabled = {\n key: 'sk-shared',\n reason: 'authentication_error',\n message: 'invalid only on messages',\n disabledAt: '2026-07-28T00:00:00Z',\n }\n const data: Record = {\n messages: response([channel('kimi-claude', 'acct-route-keys', 0, [], {\n status: 'active',\n disabledApiKeys: [disabled],\n apiKeyConfigs: [{ key: 'sk-shared', enabled: true }],\n })]),\n chat: response([channel('kimi-chat', 'acct-route-keys', 1, ['sk-shared'], {\n status: 'active',\n apiKeyConfigs: [{ key: 'sk-shared', enabled: true }],\n })]),\n responses: response([]),\n gemini: response([]),\n }\n\n const routes = buildUnifiedChannelsData(data).channels[0].protocolRoutes!\n expect(routes[0]).toMatchObject({\n kind: 'messages',\n apiKeys: [],\n apiKeyConfigs: [{ key: 'sk-shared', enabled: true }],\n disabledApiKeys: [disabled],\n })\n expect(routes[1]).toMatchObject({\n kind: 'chat',\n apiKeys: ['sk-shared'],\n apiKeyConfigs: [{ key: 'sk-shared', enabled: true }],\n disabledApiKeys: undefined,\n })\n expect(routeHasOnlyDisabledKeys(routes[0])).toBe(true)\n expect(routeHasOnlyDisabledKeys(routes[1])).toBe(false)\n })\n\n it('聚合账号合并各协议凭证并按全部路由计算状态', () => {\n const data: Record = {\n messages: response([channel('kimi-claude', 'acct-kimi', 0, [], {\n status: 'suspended',\n })]),\n chat: response([channel('kimi-chat', 'acct-kimi', 1, ['sk-kimi', 'sk-disabled'], {\n status: 'active',\n apiKeyConfigs: [{ key: 'sk-kimi', credentialUid: 'cred-kimi' }],\n disabledApiKeys: [{\n key: 'sk-disabled',\n reason: 'authentication_error',\n message: 'invalid key',\n disabledAt: '2026-07-28T00:00:00Z',\n }],\n })]),\n responses: response([]),\n gemini: response([]),\n }\n\n const [logicalChannel] = buildUnifiedChannelsData(data).channels\n expect(logicalChannel.apiKeys).toEqual(['sk-kimi', 'sk-disabled'])\n expect(logicalChannel.apiKeyConfigs).toEqual([\n { key: 'sk-kimi', credentialUid: 'cred-kimi' },\n ])\n expect(logicalChannel.disabledApiKeys).toEqual([{\n key: 'sk-disabled',\n reason: 'authentication_error',\n message: 'invalid key',\n disabledAt: '2026-07-28T00:00:00Z',\n }])\n expect(logicalChannel.status).toBe('partial')\n })\n\n it('保留各协议路由状态供聚合渠道恢复使用', () => {\n const data: Record = {\n messages: response([channel('mimo-main-claude', 'acct-main', 0, ['sk-a'], { status: 'suspended' })]),\n chat: response([channel('mimo-main-chat', 'acct-main', 1, ['sk-a'], { status: 'active' })]),\n responses: response([channel('mimo-main-codex', 'acct-main', 2, ['sk-a'], { status: 'disabled' })]),\n gemini: response([]),\n }\n\n const logicalChannel = buildUnifiedChannelsData(data).channels[0]\n expect(logicalChannel.protocolRoutes?.map(route => route.status)).toEqual([\n 'suspended',\n 'active',\n 'disabled',\n ])\n expect(resolveChannelRecoveryRoutes(logicalChannel)).toEqual([\n { kind: 'messages', index: 0, status: 'suspended' },\n { kind: 'chat', index: 1, status: 'active' },\n { kind: 'responses', index: 2, status: 'disabled' },\n ])\n })\n\n it('相同 provider 和名称下不同 accountUid 不应合并', () => {\n const data: Record = {\n messages: response([\n channel('mimo-main-claude', 'acct-a', 0, ['sk-a']),\n channel('mimo-main-claude', 'acct-b', 1, ['sk-b']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n\n expect(buildUnifiedChannelsData(data).channels).toHaveLength(2)\n })\n\n it('按 accountUid 聚合自定义自动托管的全部成功协议', () => {\n const custom = (name: string, index: number, serviceType: Channel['serviceType']): Channel =>\n channel(name, 'acct-fastaitoken', index, ['sk-fastaitoken'], { providerId: '', autoManaged: true, serviceType })\n const data: Record = {\n messages: response([{\n ...custom('fastaitoken-com-test-claude', 0, 'claude'),\n supportedModels: ['gpt-5.6-sol', 'gpt-5.6-terra'],\n }]),\n chat: response([{\n ...custom('fastaitoken-com-test-chat', 0, 'openai'),\n supportedModels: ['gpt-5.6-sol'],\n }]),\n responses: response([{\n ...custom('fastaitoken-com-test-codex', 0, 'responses'),\n supportedModels: ['codex-auto-review', 'gpt-5.6-sol'],\n }]),\n gemini: response([]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].name).toBe('fastaitoken-com-test')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT', 'CODEX'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses'])\n expect(Object.fromEntries(\n result.channels[0].protocolRoutes?.map(route => [route.kind, route.supportedModels]) ?? [],\n )).toEqual({\n messages: ['gpt-5.6-sol', 'gpt-5.6-terra'],\n chat: ['gpt-5.6-sol'],\n responses: ['codex-auto-review', 'gpt-5.6-sol'],\n })\n })\n\n it('按迁移后的 accountUid 聚合普通同站点渠道', () => {\n const data: Record = {\n messages: response([channel('ai-prism-messages', 'acct-prism', 38, ['sk-messages'], { autoManaged: false, serviceType: 'responses' })]),\n chat: response([]),\n responses: response([channel('ai-prism-responses', 'acct-prism', 30, ['sk-responses'], { autoManaged: false, serviceType: 'responses' })]),\n gemini: response([channel('prism-gemini', 'acct-prism', 9, ['sk-gemini'], { autoManaged: false, serviceType: 'gemini' })]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'responses', 'gemini'])\n expect(result.channels[0].apiKeys).toEqual(['sk-messages', 'sk-responses', 'sk-gemini'])\n })\n\n it('协议标签只展示上游实际提供的 serviceType', () => {\n const data: Record = {\n messages: response([channel('volcengine-claude', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'claude',\n })]),\n chat: response([channel('volcengine-chat', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n responses: response([channel('volcengine-codex', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n gemini: response([channel('volcengine-gemini', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n }\n\n const [volcengine] = buildUnifiedChannelsData(data).channels\n expect(volcengine.protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(volcengine.protocolRoutes).toHaveLength(4)\n expect(volcengine.protocolRoutes?.every(route => route.supportedModels === undefined)).toBe(true)\n })\n\n it('新增单协议渠道置顶后保持多协议账号的既有相对顺序', () => {\n const data: Record = {\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '', priority: 0 }),\n channel('volcengine-claude', 'acct-volcengine', 1, ['sk-volcengine'], { providerId: 'volcengine', priority: 1 }),\n channel('mimo-claude', 'acct-mimo', 2, ['sk-mimo'], { priority: 2 }),\n ]),\n chat: response([\n channel('volcengine-chat', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-chat', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('desktop-deepseek-chat', 'acct-deepseek', 34, ['sk-deepseek'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n responses: response([\n channel('volcengine-codex', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-codex', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('aixoras-xanqfm', 'acct-aixoras', 2, ['sk-aixoras'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n gemini: response([\n channel('volcengine-gemini', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-gemini', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n ]),\n }\n\n const channels = buildUnifiedChannelsData(data).channels\n const sorted = [...channels].sort((a, b) => (a.priority ?? a.index) - (b.priority ?? b.index))\n\n expect(sorted.slice(0, 5).map(item => item.name)).toEqual([\n 'localhost-37zq4d',\n 'volcengine',\n 'mimo',\n 'desktop-deepseek-chat',\n 'aixoras-xanqfm',\n ])\n expect(channels.find(item => item.name === 'mimo')?.priority).toBe(1)\n })\n\n it('在列表头部插入渠道时保持既有逻辑渠道的展示 key 稳定', () => {\n const original: Record = {\n messages: response([\n channel('volcengine-claude', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine' }),\n channel('mimo-claude', 'acct-mimo', 1, ['sk-mimo']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n const withNewChannel: Record = {\n ...original,\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '' }),\n ...original.messages.channels,\n ]),\n }\n\n const originalMimo = buildUnifiedChannelsData(original).channels.find(item => item.name === 'mimo')\n const nextMimo = buildUnifiedChannelsData(withNewChannel).channels.find(item => item.name === 'mimo')\n\n expect(nextMimo?.displayKey).toBe(originalMimo?.displayKey)\n })\n\n it('聚合逻辑渠道全部协议的最近请求活动', () => {\n const data: Record = {\n messages: response([channel('mimo-claude', 'acct-mimo', 0, ['sk-mimo'])]),\n chat: response([channel('mimo-chat', 'acct-mimo', 1, ['sk-mimo'])]),\n responses: response([]),\n gemini: response([]),\n }\n const [logicalChannel] = buildUnifiedChannelsData(data).channels\n const activity = (\n channelIndex: number,\n requestCount: number,\n successCount: number,\n failureCount: number,\n ): ChannelRecentActivity => ({\n channelIndex,\n segments: {\n 4: { requestCount, successCount, failureCount, inputTokens: requestCount * 10, outputTokens: requestCount * 2 },\n },\n totalSegs: 150,\n rpm: requestCount / 15,\n tpm: requestCount * 2 / 15,\n })\n\n const [merged] = buildUnifiedRecentActivity([logicalChannel], {\n messages: [activity(0, 2, 2, 0)],\n chat: [activity(1, 3, 2, 1)],\n responses: [],\n gemini: [],\n })\n\n expect(merged.channelIndex).toBe(0)\n expect(merged.routeKind).toBe('messages')\n expect(merged.segments[4]).toEqual({\n requestCount: 5,\n successCount: 4,\n failureCount: 1,\n inputTokens: 50,\n outputTokens: 10,\n })\n expect(merged.rpm).toBeCloseTo(5 / 15)\n expect(merged.tpm).toBeCloseTo(10 / 15)\n })\n})\n\ndescribe('buildUnifiedReorderPayloads', () => {\n it('priority 使用统一列表全局位次而非各协议组内名次', () => {\n const data: Record = {\n messages: response([\n channel('a-claude', 'acct-a', 0, ['sk-a']),\n channel('b-claude', 'acct-b', 1, ['sk-b']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([channel('g-gemini', 'acct-g', 0, ['sk-g'], { serviceType: 'gemini' })]),\n }\n const unified = buildUnifiedChannelsData(data).channels\n const a = unified.find(c => c.protocolRoutes?.some(r => r.kind === 'messages' && r.index === 0))!\n const b = unified.find(c => c.protocolRoutes?.some(r => r.kind === 'messages' && r.index === 1))!\n const g = unified.find(c => c.protocolRoutes?.some(r => r.kind === 'gemini'))!\n\n // 模拟拖拽后的统一列表顺序:a, g, b\n const payloads = buildUnifiedReorderPayloads([a, g, b])\n\n // messages:a 位次 1,b 位次 3(中间隔着 gemini 渠道)\n expect(payloads.get('messages')).toEqual({ order: [0, 1], priorities: [1, 3] })\n // gemini:g 位次 2。若按组内名次编号会得到 1,刷新后 min(priority) 会把 g 提到最前\n expect(payloads.get('gemini')).toEqual({ order: [0], priorities: [2] })\n expect(payloads.has('chat')).toBe(false)\n })\n\n it('无 protocolRoutes 的渠道不产生载荷', () => {\n const payloads = buildUnifiedReorderPayloads([{ name: 'plain', index: 0 } as Channel])\n expect(payloads.size).toBe(0)\n })\n})\n"}, "ef6111d36ca02584126ce19c13b01bd7e757c6ee": {"t": 1785759316.8219543, "n": 18872, "h": "6c3f77c4b7979cc6", "c": "import { describe, expect, it } from 'vitest'\n\nimport type { Channel, ChannelMetrics, ChannelRecentActivity, ChannelsResponse } from '@/services/api'\nimport { buildUnifiedChannelsData, buildUnifiedRecentActivity, buildUnifiedReorderPayloads, normalizeChannelStatus, PartialRouteOperationError, resolveChannelStatusMutationRoutes, resolveChannelRecoveryRoutes, routeHasOnlyDisabledKeys, summarizeSettledRouteOperations, withRouteKindMetrics, type LlmChannelKind } from './unifiedChannels'\n\nconst channel = (\n name: string,\n accountUid: string,\n index: number,\n apiKeys: string[],\n overrides: Partial = {},\n): Channel => ({\n name,\n accountUid,\n channelUid: `ch-${index}`,\n providerId: 'mimo',\n autoManaged: true,\n index,\n serviceType: name.endsWith('-claude') ? 'claude' : 'openai',\n baseUrl: 'https://example.com',\n apiKeys,\n ...overrides,\n})\n\nconst response = (channels: Channel[]): ChannelsResponse => ({ channels, current: -1 })\n\ndescribe('logical channel display status', () => {\n const groupedStatus = (...statuses: Array) => {\n const kinds: LlmChannelKind[] = ['messages', 'chat', 'responses', 'gemini']\n const data = Object.fromEntries(kinds.map(kind => [kind, response([])])) as Record\n statuses.forEach((status, index) => {\n const kind = kinds[index]\n data[kind] = response([channel(`kimi-${kind}`, 'acct-status', index, ['sk-status'], { status })])\n })\n return buildUnifiedChannelsData(data).channels[0]\n }\n\n it.each([\n [undefined, 'active'],\n ['', 'active'],\n ['healthy', 'active'],\n ['active', 'active'],\n ['suspended', 'suspended'],\n ['disabled', 'disabled'],\n ['error', 'error'],\n ['unknown', 'unknown'],\n ] as const)('统一状态 %s 归一化为 %s', (status, expected) => {\n expect(normalizeChannelStatus(status)).toBe(expected)\n expect(groupedStatus(status, status).status).toBe(expected)\n })\n\n it.each([\n ['active', 'suspended'],\n ['active', 'disabled'],\n ['suspended', 'disabled'],\n ['healthy', 'suspended'],\n [undefined, 'disabled'],\n ] as const)('混合状态 %s + %s 聚合为 partial', (left, right) => {\n expect(groupedStatus(left, right).status).toBe('partial')\n })\n\n it('Kimi 类 messages 暂停而 chat/responses 活跃时显示部分可用并保留物理状态', () => {\n const logicalChannel = groupedStatus('suspended', 'active', 'active')\n expect(logicalChannel.status).toBe('partial')\n expect(logicalChannel.protocolRoutes?.map(route => route.status)).toEqual([\n 'suspended',\n 'active',\n 'active',\n ])\n })\n})\n\ndescribe('channel status mutations', () => {\n const logicalChannel = (): Channel => ({\n ...channel('kimi-claude', 'acct-kimi', 0, ['sk-kimi'], { status: 'partial' }),\n protocolRoutes: [\n { kind: 'messages', index: 0, name: 'kimi-claude', serviceType: 'claude', status: 'suspended' },\n { kind: 'chat', index: 1, name: 'kimi-chat', serviceType: 'openai', status: 'active' },\n { kind: 'responses', index: 2, name: 'kimi-codex', serviceType: 'responses', status: 'active' },\n ],\n })\n\n it('状态更新只生成 transport status,不会把 partial 发送给 API', () => {\n expect(resolveChannelStatusMutationRoutes(logicalChannel(), 'suspended')).toEqual([\n { kind: 'messages', index: 0, status: 'suspended' },\n { kind: 'chat', index: 1, status: 'suspended' },\n { kind: 'responses', index: 2, status: 'suspended' },\n ])\n expect(() => resolveChannelStatusMutationRoutes(logicalChannel(), 'partial')).toThrow(\n 'Display-only channel status cannot be persisted: partial',\n )\n })\n\n it('暂停 active+disabled 的部分可用渠道时保留 disabled 路由', () => {\n const mixed = logicalChannel()\n mixed.protocolRoutes![0].status = 'active'\n mixed.protocolRoutes![1].status = 'disabled'\n expect(resolveChannelStatusMutationRoutes(mixed, 'suspended')).toEqual([\n { kind: 'messages', index: 0, status: 'suspended' },\n { kind: 'responses', index: 2, status: 'suspended' },\n ])\n })\n\n it('显式禁用逻辑渠道时 fan-out 到全部物理路由', () => {\n const mixed = logicalChannel()\n mixed.protocolRoutes![1].status = 'disabled'\n expect(resolveChannelStatusMutationRoutes(mixed, 'disabled')).toEqual([\n { kind: 'messages', index: 0, status: 'disabled' },\n { kind: 'chat', index: 1, status: 'disabled' },\n { kind: 'responses', index: 2, status: 'disabled' },\n ])\n })\n})\n\ndescribe('settled route operations', () => {\n it('保留部分成功与失败计数,供组件刷新后提示部分失败', () => {\n const summary = summarizeSettledRouteOperations([\n { status: 'fulfilled', value: 2 },\n { status: 'rejected', reason: new Error('chat failed') },\n { status: 'fulfilled', value: 1 },\n ])\n expect(summary).toMatchObject({ fulfilled: [2, 1], successCount: 2, failureCount: 1, totalCount: 3 })\n const error = new PartialRouteOperationError(summary)\n expect(error).toMatchObject({ successCount: 2, failureCount: 1, totalCount: 3 })\n expect(error.message).toContain('chat failed')\n })\n})\n\ndescribe('withRouteKindMetrics', () => {\n const metric = (routeKind?: Channel['routeKind']): ChannelMetrics => ({\n channelIndex: 1,\n routeKind,\n requestCount: 0,\n successCount: 0,\n failureCount: 0,\n successRate: 0,\n errorRate: 0,\n consecutiveFailures: 0,\n latency: 0,\n })\n\n it('routeKind 已存在时复用指标数组和对象引用', () => {\n const metrics = [metric('messages')]\n expect(withRouteKindMetrics('messages', metrics)).toBe(metrics)\n })\n\n it('仅为旧格式指标补充 routeKind', () => {\n const metrics = [metric()]\n const result = withRouteKindMetrics('chat', metrics)\n expect(result).not.toBe(metrics)\n expect(result[0]).toEqual({ ...metrics[0], routeKind: 'chat' })\n })\n})\n\ndescribe('buildUnifiedChannelsData account grouping', () => {\n it('优先按 accountUid 聚合多协议渠道,不依赖 Key 指纹', () => {\n const data: Record = {\n messages: response([channel('mimo-main-claude', 'acct-main', 0, ['sk-a'])]),\n chat: response([channel('mimo-main-chat', 'acct-main', 1, ['sk-a', 'sk-b'])]),\n responses: response([channel('mimo-main-codex', 'acct-main', 2, ['sk-b'])]),\n gemini: response([channel('mimo-main-gemini', 'acct-main', 3, ['sk-a'])]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].accountUid).toBe('acct-main')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses', 'gemini'])\n expect(result.channels[0].protocolRoutes?.map(item => item.status)).toEqual([undefined, undefined, undefined, undefined])\n expect(result.channels[0].protocolRoutes?.map(item => item.apiKeys)).toEqual([\n ['sk-a'],\n ['sk-a', 'sk-b'],\n ['sk-b'],\n ['sk-a'],\n ])\n })\n\n it('保留物理路由各自的 Key 配置与拉黑状态,避免共享 Key 误判', () => {\n const disabled = {\n key: 'sk-shared',\n reason: 'authentication_error',\n message: 'invalid only on messages',\n disabledAt: '2026-07-28T00:00:00Z',\n }\n const data: Record = {\n messages: response([channel('kimi-claude', 'acct-route-keys', 0, [], {\n status: 'active',\n disabledApiKeys: [disabled],\n apiKeyConfigs: [{ key: 'sk-shared', enabled: true }],\n })]),\n chat: response([channel('kimi-chat', 'acct-route-keys', 1, ['sk-shared'], {\n status: 'active',\n apiKeyConfigs: [{ key: 'sk-shared', enabled: true }],\n })]),\n responses: response([]),\n gemini: response([]),\n }\n\n const routes = buildUnifiedChannelsData(data).channels[0].protocolRoutes!\n expect(routes[0]).toMatchObject({\n kind: 'messages',\n apiKeys: [],\n apiKeyConfigs: [{ key: 'sk-shared', enabled: true }],\n disabledApiKeys: [disabled],\n })\n expect(routes[1]).toMatchObject({\n kind: 'chat',\n apiKeys: ['sk-shared'],\n apiKeyConfigs: [{ key: 'sk-shared', enabled: true }],\n disabledApiKeys: undefined,\n })\n expect(routeHasOnlyDisabledKeys(routes[0])).toBe(true)\n expect(routeHasOnlyDisabledKeys(routes[1])).toBe(false)\n })\n\n it('聚合账号合并各协议凭证并按全部路由计算状态', () => {\n const data: Record = {\n messages: response([channel('kimi-claude', 'acct-kimi', 0, [], {\n status: 'suspended',\n })]),\n chat: response([channel('kimi-chat', 'acct-kimi', 1, ['sk-kimi', 'sk-disabled'], {\n status: 'active',\n apiKeyConfigs: [{ key: 'sk-kimi', credentialUid: 'cred-kimi' }],\n disabledApiKeys: [{\n key: 'sk-disabled',\n reason: 'authentication_error',\n message: 'invalid key',\n disabledAt: '2026-07-28T00:00:00Z',\n }],\n })]),\n responses: response([]),\n gemini: response([]),\n }\n\n const [logicalChannel] = buildUnifiedChannelsData(data).channels\n expect(logicalChannel.apiKeys).toEqual(['sk-kimi', 'sk-disabled'])\n expect(logicalChannel.apiKeyConfigs).toEqual([\n { key: 'sk-kimi', credentialUid: 'cred-kimi' },\n ])\n expect(logicalChannel.disabledApiKeys).toEqual([{\n key: 'sk-disabled',\n reason: 'authentication_error',\n message: 'invalid key',\n disabledAt: '2026-07-28T00:00:00Z',\n }])\n expect(logicalChannel.status).toBe('partial')\n })\n\n it('保留各协议路由状态供聚合渠道恢复使用', () => {\n const data: Record = {\n messages: response([channel('mimo-main-claude', 'acct-main', 0, ['sk-a'], { status: 'suspended' })]),\n chat: response([channel('mimo-main-chat', 'acct-main', 1, ['sk-a'], { status: 'active' })]),\n responses: response([channel('mimo-main-codex', 'acct-main', 2, ['sk-a'], { status: 'disabled' })]),\n gemini: response([]),\n }\n\n const logicalChannel = buildUnifiedChannelsData(data).channels[0]\n expect(logicalChannel.protocolRoutes?.map(route => route.status)).toEqual([\n 'suspended',\n 'active',\n 'disabled',\n ])\n expect(resolveChannelRecoveryRoutes(logicalChannel)).toEqual([\n { kind: 'messages', index: 0, status: 'suspended' },\n { kind: 'chat', index: 1, status: 'active' },\n { kind: 'responses', index: 2, status: 'disabled' },\n ])\n })\n\n it('相同 provider 和名称下不同 accountUid 不应合并', () => {\n const data: Record = {\n messages: response([\n channel('mimo-main-claude', 'acct-a', 0, ['sk-a']),\n channel('mimo-main-claude', 'acct-b', 1, ['sk-b']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n\n expect(buildUnifiedChannelsData(data).channels).toHaveLength(2)\n })\n\n it('按 accountUid 聚合自定义自动托管的全部成功协议', () => {\n const custom = (name: string, index: number, serviceType: Channel['serviceType']): Channel =>\n channel(name, 'acct-fastaitoken', index, ['sk-fastaitoken'], { providerId: '', autoManaged: true, serviceType })\n const data: Record = {\n messages: response([{\n ...custom('fastaitoken-com-test-claude', 0, 'claude'),\n supportedModels: ['gpt-5.6-sol', 'gpt-5.6-terra'],\n }]),\n chat: response([{\n ...custom('fastaitoken-com-test-chat', 0, 'openai'),\n supportedModels: ['gpt-5.6-sol'],\n }]),\n responses: response([{\n ...custom('fastaitoken-com-test-codex', 0, 'responses'),\n supportedModels: ['codex-auto-review', 'gpt-5.6-sol'],\n }]),\n gemini: response([]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].name).toBe('fastaitoken-com-test')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT', 'CODEX'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses'])\n expect(Object.fromEntries(\n result.channels[0].protocolRoutes?.map(route => [route.kind, route.supportedModels]) ?? [],\n )).toEqual({\n messages: ['gpt-5.6-sol', 'gpt-5.6-terra'],\n chat: ['gpt-5.6-sol'],\n responses: ['codex-auto-review', 'gpt-5.6-sol'],\n })\n })\n\n it('协议标签只展示上游实际提供的 serviceType', () => {\n const data: Record = {\n messages: response([channel('volcengine-claude', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'claude',\n })]),\n chat: response([channel('volcengine-chat', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n responses: response([channel('volcengine-codex', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n gemini: response([channel('volcengine-gemini', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n }\n\n const [volcengine] = buildUnifiedChannelsData(data).channels\n expect(volcengine.protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(volcengine.protocolRoutes).toHaveLength(4)\n expect(volcengine.protocolRoutes?.every(route => route.supportedModels === undefined)).toBe(true)\n })\n\n it('新增单协议渠道置顶后保持多协议账号的既有相对顺序', () => {\n const data: Record = {\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '', priority: 0 }),\n channel('volcengine-claude', 'acct-volcengine', 1, ['sk-volcengine'], { providerId: 'volcengine', priority: 1 }),\n channel('mimo-claude', 'acct-mimo', 2, ['sk-mimo'], { priority: 2 }),\n ]),\n chat: response([\n channel('volcengine-chat', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-chat', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('desktop-deepseek-chat', 'acct-deepseek', 34, ['sk-deepseek'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n responses: response([\n channel('volcengine-codex', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-codex', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('aixoras-xanqfm', 'acct-aixoras', 2, ['sk-aixoras'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n gemini: response([\n channel('volcengine-gemini', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-gemini', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n ]),\n }\n\n const channels = buildUnifiedChannelsData(data).channels\n const sorted = [...channels].sort((a, b) => (a.priority ?? a.index) - (b.priority ?? b.index))\n\n expect(sorted.slice(0, 5).map(item => item.name)).toEqual([\n 'localhost-37zq4d',\n 'volcengine',\n 'mimo',\n 'desktop-deepseek-chat',\n 'aixoras-xanqfm',\n ])\n expect(channels.find(item => item.name === 'mimo')?.priority).toBe(1)\n })\n\n it('在列表头部插入渠道时保持既有逻辑渠道的展示 key 稳定', () => {\n const original: Record = {\n messages: response([\n channel('volcengine-claude', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine' }),\n channel('mimo-claude', 'acct-mimo', 1, ['sk-mimo']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n const withNewChannel: Record = {\n ...original,\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '' }),\n ...original.messages.channels,\n ]),\n }\n\n const originalMimo = buildUnifiedChannelsData(original).channels.find(item => item.name === 'mimo')\n const nextMimo = buildUnifiedChannelsData(withNewChannel).channels.find(item => item.name === 'mimo')\n\n expect(nextMimo?.displayKey).toBe(originalMimo?.displayKey)\n })\n\n it('聚合逻辑渠道全部协议的最近请求活动', () => {\n const data: Record = {\n messages: response([channel('mimo-claude', 'acct-mimo', 0, ['sk-mimo'])]),\n chat: response([channel('mimo-chat', 'acct-mimo', 1, ['sk-mimo'])]),\n responses: response([]),\n gemini: response([]),\n }\n const [logicalChannel] = buildUnifiedChannelsData(data).channels\n const activity = (\n channelIndex: number,\n requestCount: number,\n successCount: number,\n failureCount: number,\n ): ChannelRecentActivity => ({\n channelIndex,\n segments: {\n 4: { requestCount, successCount, failureCount, inputTokens: requestCount * 10, outputTokens: requestCount * 2 },\n },\n totalSegs: 150,\n rpm: requestCount / 15,\n tpm: requestCount * 2 / 15,\n })\n\n const [merged] = buildUnifiedRecentActivity([logicalChannel], {\n messages: [activity(0, 2, 2, 0)],\n chat: [activity(1, 3, 2, 1)],\n responses: [],\n gemini: [],\n })\n\n expect(merged.channelIndex).toBe(0)\n expect(merged.routeKind).toBe('messages')\n expect(merged.segments[4]).toEqual({\n requestCount: 5,\n successCount: 4,\n failureCount: 1,\n inputTokens: 50,\n outputTokens: 10,\n })\n expect(merged.rpm).toBeCloseTo(5 / 15)\n expect(merged.tpm).toBeCloseTo(10 / 15)\n })\n})\n\ndescribe('buildUnifiedReorderPayloads', () => {\n it('priority 使用统一列表全局位次而非各协议组内名次', () => {\n const data: Record = {\n messages: response([\n channel('a-claude', 'acct-a', 0, ['sk-a']),\n channel('b-claude', 'acct-b', 1, ['sk-b']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([channel('g-gemini', 'acct-g', 0, ['sk-g'], { serviceType: 'gemini' })]),\n }\n const unified = buildUnifiedChannelsData(data).channels\n const a = unified.find(c => c.protocolRoutes?.some(r => r.kind === 'messages' && r.index === 0))!\n const b = unified.find(c => c.protocolRoutes?.some(r => r.kind === 'messages' && r.index === 1))!\n const g = unified.find(c => c.protocolRoutes?.some(r => r.kind === 'gemini'))!\n\n // 模拟拖拽后的统一列表顺序:a, g, b\n const payloads = buildUnifiedReorderPayloads([a, g, b])\n\n // messages:a 位次 1,b 位次 3(中间隔着 gemini 渠道)\n expect(payloads.get('messages')).toEqual({ order: [0, 1], priorities: [1, 3] })\n // gemini:g 位次 2。若按组内名次编号会得到 1,刷新后 min(priority) 会把 g 提到最前\n expect(payloads.get('gemini')).toEqual({ order: [0], priorities: [2] })\n expect(payloads.has('chat')).toBe(false)\n })\n\n it('无 protocolRoutes 的渠道不产生载荷', () => {\n const payloads = buildUnifiedReorderPayloads([{ name: 'plain', index: 0 } as Channel])\n expect(payloads.size).toBe(0)\n })\n})\n"}, "5ee83db2a835f623edc4d55f1fcb0b0bf65bcd1d": {"t": 1785759318.6136136, "n": 13403, "h": "8dd746fd41495217", "c": "import { describe, expect, it } from 'vitest'\n\nimport type { Channel, ChannelMetrics, ChannelRecentActivity, ChannelsResponse } from '@/services/api'\nimport { buildUnifiedChannelsData, buildUnifiedRecentActivity, buildUnifiedReorderPayloads, resolveChannelRecoveryRoutes, withRouteKindMetrics, type LlmChannelKind } from './unifiedChannels'\n\nconst channel = (\n name: string,\n accountUid: string,\n index: number,\n apiKeys: string[],\n overrides: Partial = {},\n): Channel => ({\n name,\n accountUid,\n channelUid: `ch-${index}`,\n providerId: 'mimo',\n autoManaged: true,\n index,\n serviceType: name.endsWith('-claude') ? 'claude' : 'openai',\n baseUrl: 'https://example.com',\n apiKeys,\n ...overrides,\n})\n\nconst response = (channels: Channel[]): ChannelsResponse => ({ channels, current: -1 })\n\ndescribe('withRouteKindMetrics', () => {\n const metric = (routeKind?: Channel['routeKind']): ChannelMetrics => ({\n channelIndex: 1,\n routeKind,\n requestCount: 0,\n successCount: 0,\n failureCount: 0,\n successRate: 0,\n errorRate: 0,\n consecutiveFailures: 0,\n latency: 0,\n })\n\n it('routeKind 已存在时复用指标数组和对象引用', () => {\n const metrics = [metric('messages')]\n expect(withRouteKindMetrics('messages', metrics)).toBe(metrics)\n })\n\n it('仅为旧格式指标补充 routeKind', () => {\n const metrics = [metric()]\n const result = withRouteKindMetrics('chat', metrics)\n expect(result).not.toBe(metrics)\n expect(result[0]).toEqual({ ...metrics[0], routeKind: 'chat' })\n })\n})\n\ndescribe('buildUnifiedChannelsData account grouping', () => {\n it('优先按 accountUid 聚合多协议渠道,不依赖 Key 指纹', () => {\n const data: Record = {\n messages: response([channel('mimo-main-claude', 'acct-main', 0, ['sk-a'])]),\n chat: response([channel('mimo-main-chat', 'acct-main', 1, ['sk-a', 'sk-b'])]),\n responses: response([channel('mimo-main-codex', 'acct-main', 2, ['sk-b'])]),\n gemini: response([channel('mimo-main-gemini', 'acct-main', 3, ['sk-a'])]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].accountUid).toBe('acct-main')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses', 'gemini'])\n expect(result.channels[0].protocolRoutes?.map(item => item.status)).toEqual([undefined, undefined, undefined, undefined])\n expect(result.channels[0].protocolRoutes?.map(item => item.apiKeys)).toEqual([\n ['sk-a'],\n ['sk-a', 'sk-b'],\n ['sk-b'],\n ['sk-a'],\n ])\n })\n\n it('聚合账号合并各协议凭证并按全部路由计算状态', () => {\n const data: Record = {\n messages: response([channel('kimi-claude', 'acct-kimi', 0, [], {\n status: 'suspended',\n })]),\n chat: response([channel('kimi-chat', 'acct-kimi', 1, ['sk-kimi', 'sk-disabled'], {\n status: 'active',\n apiKeyConfigs: [{ key: 'sk-kimi', credentialUid: 'cred-kimi' }],\n disabledApiKeys: [{\n key: 'sk-disabled',\n reason: 'authentication_error',\n message: 'invalid key',\n disabledAt: '2026-07-28T00:00:00Z',\n }],\n })]),\n responses: response([]),\n gemini: response([]),\n }\n\n const [logicalChannel] = buildUnifiedChannelsData(data).channels\n expect(logicalChannel.apiKeys).toEqual(['sk-kimi', 'sk-disabled'])\n expect(logicalChannel.apiKeyConfigs).toEqual([\n { key: 'sk-kimi', credentialUid: 'cred-kimi' },\n ])\n expect(logicalChannel.disabledApiKeys).toEqual([{\n key: 'sk-disabled',\n reason: 'authentication_error',\n message: 'invalid key',\n disabledAt: '2026-07-28T00:00:00Z',\n }])\n expect(logicalChannel.status).toBe('active')\n })\n\n it('保留各协议路由状态供聚合渠道恢复使用', () => {\n const data: Record = {\n messages: response([channel('mimo-main-claude', 'acct-main', 0, ['sk-a'], { status: 'suspended' })]),\n chat: response([channel('mimo-main-chat', 'acct-main', 1, ['sk-a'], { status: 'active' })]),\n responses: response([channel('mimo-main-codex', 'acct-main', 2, ['sk-a'], { status: 'disabled' })]),\n gemini: response([]),\n }\n\n const logicalChannel = buildUnifiedChannelsData(data).channels[0]\n expect(logicalChannel.protocolRoutes?.map(route => route.status)).toEqual([\n 'suspended',\n 'active',\n 'disabled',\n ])\n expect(resolveChannelRecoveryRoutes(logicalChannel)).toEqual([\n { kind: 'messages', index: 0, status: 'suspended' },\n { kind: 'chat', index: 1, status: 'active' },\n { kind: 'responses', index: 2, status: 'disabled' },\n ])\n })\n\n it('相同 provider 和名称下不同 accountUid 不应合并', () => {\n const data: Record = {\n messages: response([\n channel('mimo-main-claude', 'acct-a', 0, ['sk-a']),\n channel('mimo-main-claude', 'acct-b', 1, ['sk-b']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n\n expect(buildUnifiedChannelsData(data).channels).toHaveLength(2)\n })\n\n it('按 accountUid 聚合自定义自动托管的全部成功协议', () => {\n const custom = (name: string, index: number, serviceType: Channel['serviceType']): Channel =>\n channel(name, 'acct-fastaitoken', index, ['sk-fastaitoken'], { providerId: '', autoManaged: true, serviceType })\n const data: Record = {\n messages: response([{\n ...custom('fastaitoken-com-test-claude', 0, 'claude'),\n supportedModels: ['gpt-5.6-sol', 'gpt-5.6-terra'],\n }]),\n chat: response([{\n ...custom('fastaitoken-com-test-chat', 0, 'openai'),\n supportedModels: ['gpt-5.6-sol'],\n }]),\n responses: response([{\n ...custom('fastaitoken-com-test-codex', 0, 'responses'),\n supportedModels: ['codex-auto-review', 'gpt-5.6-sol'],\n }]),\n gemini: response([]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].name).toBe('fastaitoken-com-test')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT', 'CODEX'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses'])\n expect(Object.fromEntries(\n result.channels[0].protocolRoutes?.map(route => [route.kind, route.supportedModels]) ?? [],\n )).toEqual({\n messages: ['gpt-5.6-sol', 'gpt-5.6-terra'],\n chat: ['gpt-5.6-sol'],\n responses: ['codex-auto-review', 'gpt-5.6-sol'],\n })\n })\n\n it('协议标签只展示上游实际提供的 serviceType', () => {\n const data: Record = {\n messages: response([channel('volcengine-claude', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'claude',\n })]),\n chat: response([channel('volcengine-chat', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n responses: response([channel('volcengine-codex', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n gemini: response([channel('volcengine-gemini', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n }\n\n const [volcengine] = buildUnifiedChannelsData(data).channels\n expect(volcengine.protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(volcengine.protocolRoutes).toHaveLength(4)\n expect(volcengine.protocolRoutes?.every(route => route.supportedModels === undefined)).toBe(true)\n })\n\n it('新增单协议渠道置顶后保持多协议账号的既有相对顺序', () => {\n const data: Record = {\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '', priority: 0 }),\n channel('volcengine-claude', 'acct-volcengine', 1, ['sk-volcengine'], { providerId: 'volcengine', priority: 1 }),\n channel('mimo-claude', 'acct-mimo', 2, ['sk-mimo'], { priority: 2 }),\n ]),\n chat: response([\n channel('volcengine-chat', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-chat', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('desktop-deepseek-chat', 'acct-deepseek', 34, ['sk-deepseek'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n responses: response([\n channel('volcengine-codex', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-codex', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('aixoras-xanqfm', 'acct-aixoras', 2, ['sk-aixoras'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n gemini: response([\n channel('volcengine-gemini', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-gemini', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n ]),\n }\n\n const channels = buildUnifiedChannelsData(data).channels\n const sorted = [...channels].sort((a, b) => (a.priority ?? a.index) - (b.priority ?? b.index))\n\n expect(sorted.slice(0, 5).map(item => item.name)).toEqual([\n 'localhost-37zq4d',\n 'volcengine',\n 'mimo',\n 'desktop-deepseek-chat',\n 'aixoras-xanqfm',\n ])\n expect(channels.find(item => item.name === 'mimo')?.priority).toBe(1)\n })\n\n it('在列表头部插入渠道时保持既有逻辑渠道的展示 key 稳定', () => {\n const original: Record = {\n messages: response([\n channel('volcengine-claude', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine' }),\n channel('mimo-claude', 'acct-mimo', 1, ['sk-mimo']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n const withNewChannel: Record = {\n ...original,\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '' }),\n ...original.messages.channels,\n ]),\n }\n\n const originalMimo = buildUnifiedChannelsData(original).channels.find(item => item.name === 'mimo')\n const nextMimo = buildUnifiedChannelsData(withNewChannel).channels.find(item => item.name === 'mimo')\n\n expect(nextMimo?.displayKey).toBe(originalMimo?.displayKey)\n })\n\n it('聚合逻辑渠道全部协议的最近请求活动', () => {\n const data: Record = {\n messages: response([channel('mimo-claude', 'acct-mimo', 0, ['sk-mimo'])]),\n chat: response([channel('mimo-chat', 'acct-mimo', 1, ['sk-mimo'])]),\n responses: response([]),\n gemini: response([]),\n }\n const [logicalChannel] = buildUnifiedChannelsData(data).channels\n const activity = (\n channelIndex: number,\n requestCount: number,\n successCount: number,\n failureCount: number,\n ): ChannelRecentActivity => ({\n channelIndex,\n segments: {\n 4: { requestCount, successCount, failureCount, inputTokens: requestCount * 10, outputTokens: requestCount * 2 },\n },\n totalSegs: 150,\n rpm: requestCount / 15,\n tpm: requestCount * 2 / 15,\n })\n\n const [merged] = buildUnifiedRecentActivity([logicalChannel], {\n messages: [activity(0, 2, 2, 0)],\n chat: [activity(1, 3, 2, 1)],\n responses: [],\n gemini: [],\n })\n\n expect(merged.channelIndex).toBe(0)\n expect(merged.routeKind).toBe('messages')\n expect(merged.segments[4]).toEqual({\n requestCount: 5,\n successCount: 4,\n failureCount: 1,\n inputTokens: 50,\n outputTokens: 10,\n })\n expect(merged.rpm).toBeCloseTo(5 / 15)\n expect(merged.tpm).toBeCloseTo(10 / 15)\n })\n})\n\ndescribe('buildUnifiedReorderPayloads', () => {\n it('priority 使用统一列表全局位次而非各协议组内名次', () => {\n const data: Record = {\n messages: response([\n channel('a-claude', 'acct-a', 0, ['sk-a']),\n channel('b-claude', 'acct-b', 1, ['sk-b']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([channel('g-gemini', 'acct-g', 0, ['sk-g'], { serviceType: 'gemini' })]),\n }\n const unified = buildUnifiedChannelsData(data).channels\n const a = unified.find(c => c.protocolRoutes?.some(r => r.kind === 'messages' && r.index === 0))!\n const b = unified.find(c => c.protocolRoutes?.some(r => r.kind === 'messages' && r.index === 1))!\n const g = unified.find(c => c.protocolRoutes?.some(r => r.kind === 'gemini'))!\n\n // 模拟拖拽后的统一列表顺序:a, g, b\n const payloads = buildUnifiedReorderPayloads([a, g, b])\n\n // messages:a 位次 1,b 位次 3(中间隔着 gemini 渠道)\n expect(payloads.get('messages')).toEqual({ order: [0, 1], priorities: [1, 3] })\n // gemini:g 位次 2。若按组内名次编号会得到 1,刷新后 min(priority) 会把 g 提到最前\n expect(payloads.get('gemini')).toEqual({ order: [0], priorities: [2] })\n expect(payloads.has('chat')).toBe(false)\n })\n\n it('无 protocolRoutes 的渠道不产生载荷', () => {\n const payloads = buildUnifiedReorderPayloads([{ name: 'plain', index: 0 } as Channel])\n expect(payloads.size).toBe(0)\n })\n})\n"}, "a52c5a30edd5aeae9b47fd8462ebc43c3a937836": {"t": 1785759321.3108041, "n": 12668, "h": "7642197642609c33", "c": "import { describe, expect, it } from 'vitest'\n\nimport type { Channel, ChannelRecentActivity, ChannelsResponse } from '@/services/api'\nimport { buildUnifiedChannelsData, buildUnifiedRecentActivity, buildUnifiedReorderPayloads, resolveChannelRecoveryRoutes, type LlmChannelKind } from './unifiedChannels'\n\nconst channel = (\n name: string,\n accountUid: string,\n index: number,\n apiKeys: string[],\n overrides: Partial = {},\n): Channel => ({\n name,\n accountUid,\n channelUid: `ch-${index}`,\n providerId: 'mimo',\n autoManaged: true,\n index,\n serviceType: name.endsWith('-claude') ? 'claude' : 'openai',\n baseUrl: 'https://example.com',\n apiKeys,\n ...overrides,\n})\n\nconst response = (channels: Channel[]): ChannelsResponse => ({ channels, current: -1 })\n\ndescribe('buildUnifiedChannelsData account grouping', () => {\n it('优先按 accountUid 聚合多协议渠道,不依赖 Key 指纹', () => {\n const data: Record = {\n messages: response([channel('mimo-main-claude', 'acct-main', 0, ['sk-a'])]),\n chat: response([channel('mimo-main-chat', 'acct-main', 1, ['sk-a', 'sk-b'])]),\n responses: response([channel('mimo-main-codex', 'acct-main', 2, ['sk-b'])]),\n gemini: response([channel('mimo-main-gemini', 'acct-main', 3, ['sk-a'])]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].accountUid).toBe('acct-main')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses', 'gemini'])\n expect(result.channels[0].protocolRoutes?.map(item => item.status)).toEqual([undefined, undefined, undefined, undefined])\n expect(result.channels[0].protocolRoutes?.map(item => item.apiKeys)).toEqual([\n ['sk-a'],\n ['sk-a', 'sk-b'],\n ['sk-b'],\n ['sk-a'],\n ])\n })\n\n it('聚合账号合并各协议凭证并按全部路由计算状态', () => {\n const data: Record = {\n messages: response([channel('kimi-claude', 'acct-kimi', 0, [], {\n status: 'suspended',\n })]),\n chat: response([channel('kimi-chat', 'acct-kimi', 1, ['sk-kimi', 'sk-disabled'], {\n status: 'active',\n apiKeyConfigs: [{ key: 'sk-kimi', credentialUid: 'cred-kimi' }],\n disabledApiKeys: [{\n key: 'sk-disabled',\n reason: 'authentication_error',\n message: 'invalid key',\n disabledAt: '2026-07-28T00:00:00Z',\n }],\n })]),\n responses: response([]),\n gemini: response([]),\n }\n\n const [logicalChannel] = buildUnifiedChannelsData(data).channels\n expect(logicalChannel.apiKeys).toEqual(['sk-kimi', 'sk-disabled'])\n expect(logicalChannel.apiKeyConfigs).toEqual([\n { key: 'sk-kimi', credentialUid: 'cred-kimi' },\n ])\n expect(logicalChannel.disabledApiKeys).toEqual([{\n key: 'sk-disabled',\n reason: 'authentication_error',\n message: 'invalid key',\n disabledAt: '2026-07-28T00:00:00Z',\n }])\n expect(logicalChannel.status).toBe('active')\n })\n\n it('保留各协议路由状态供聚合渠道恢复使用', () => {\n const data: Record = {\n messages: response([channel('mimo-main-claude', 'acct-main', 0, ['sk-a'], { status: 'suspended' })]),\n chat: response([channel('mimo-main-chat', 'acct-main', 1, ['sk-a'], { status: 'active' })]),\n responses: response([channel('mimo-main-codex', 'acct-main', 2, ['sk-a'], { status: 'disabled' })]),\n gemini: response([]),\n }\n\n const logicalChannel = buildUnifiedChannelsData(data).channels[0]\n expect(logicalChannel.protocolRoutes?.map(route => route.status)).toEqual([\n 'suspended',\n 'active',\n 'disabled',\n ])\n expect(resolveChannelRecoveryRoutes(logicalChannel)).toEqual([\n { kind: 'messages', index: 0, status: 'suspended' },\n { kind: 'chat', index: 1, status: 'active' },\n { kind: 'responses', index: 2, status: 'disabled' },\n ])\n })\n\n it('相同 provider 和名称下不同 accountUid 不应合并', () => {\n const data: Record = {\n messages: response([\n channel('mimo-main-claude', 'acct-a', 0, ['sk-a']),\n channel('mimo-main-claude', 'acct-b', 1, ['sk-b']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n\n expect(buildUnifiedChannelsData(data).channels).toHaveLength(2)\n })\n\n it('按 accountUid 聚合自定义自动托管的全部成功协议', () => {\n const custom = (name: string, index: number, serviceType: Channel['serviceType']): Channel =>\n channel(name, 'acct-fastaitoken', index, ['sk-fastaitoken'], { providerId: '', autoManaged: true, serviceType })\n const data: Record = {\n messages: response([{\n ...custom('fastaitoken-com-test-claude', 0, 'claude'),\n supportedModels: ['gpt-5.6-sol', 'gpt-5.6-terra'],\n }]),\n chat: response([{\n ...custom('fastaitoken-com-test-chat', 0, 'openai'),\n supportedModels: ['gpt-5.6-sol'],\n }]),\n responses: response([{\n ...custom('fastaitoken-com-test-codex', 0, 'responses'),\n supportedModels: ['codex-auto-review', 'gpt-5.6-sol'],\n }]),\n gemini: response([]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].name).toBe('fastaitoken-com-test')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT', 'CODEX'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses'])\n expect(Object.fromEntries(\n result.channels[0].protocolRoutes?.map(route => [route.kind, route.supportedModels]) ?? [],\n )).toEqual({\n messages: ['gpt-5.6-sol', 'gpt-5.6-terra'],\n chat: ['gpt-5.6-sol'],\n responses: ['codex-auto-review', 'gpt-5.6-sol'],\n })\n })\n\n it('协议标签只展示上游实际提供的 serviceType', () => {\n const data: Record = {\n messages: response([channel('volcengine-claude', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'claude',\n })]),\n chat: response([channel('volcengine-chat', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n responses: response([channel('volcengine-codex', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n gemini: response([channel('volcengine-gemini', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n }\n\n const [volcengine] = buildUnifiedChannelsData(data).channels\n expect(volcengine.protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(volcengine.protocolRoutes).toHaveLength(4)\n expect(volcengine.protocolRoutes?.every(route => route.supportedModels === undefined)).toBe(true)\n })\n\n it('新增单协议渠道置顶后保持多协议账号的既有相对顺序', () => {\n const data: Record = {\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '', priority: 0 }),\n channel('volcengine-claude', 'acct-volcengine', 1, ['sk-volcengine'], { providerId: 'volcengine', priority: 1 }),\n channel('mimo-claude', 'acct-mimo', 2, ['sk-mimo'], { priority: 2 }),\n ]),\n chat: response([\n channel('volcengine-chat', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-chat', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('desktop-deepseek-chat', 'acct-deepseek', 34, ['sk-deepseek'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n responses: response([\n channel('volcengine-codex', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-codex', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('aixoras-xanqfm', 'acct-aixoras', 2, ['sk-aixoras'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n gemini: response([\n channel('volcengine-gemini', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-gemini', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n ]),\n }\n\n const channels = buildUnifiedChannelsData(data).channels\n const sorted = [...channels].sort((a, b) => (a.priority ?? a.index) - (b.priority ?? b.index))\n\n expect(sorted.slice(0, 5).map(item => item.name)).toEqual([\n 'localhost-37zq4d',\n 'volcengine',\n 'mimo',\n 'desktop-deepseek-chat',\n 'aixoras-xanqfm',\n ])\n expect(channels.find(item => item.name === 'mimo')?.priority).toBe(1)\n })\n\n it('在列表头部插入渠道时保持既有逻辑渠道的展示 key 稳定', () => {\n const original: Record = {\n messages: response([\n channel('volcengine-claude', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine' }),\n channel('mimo-claude', 'acct-mimo', 1, ['sk-mimo']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n const withNewChannel: Record = {\n ...original,\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '' }),\n ...original.messages.channels,\n ]),\n }\n\n const originalMimo = buildUnifiedChannelsData(original).channels.find(item => item.name === 'mimo')\n const nextMimo = buildUnifiedChannelsData(withNewChannel).channels.find(item => item.name === 'mimo')\n\n expect(nextMimo?.displayKey).toBe(originalMimo?.displayKey)\n })\n\n it('聚合逻辑渠道全部协议的最近请求活动', () => {\n const data: Record = {\n messages: response([channel('mimo-claude', 'acct-mimo', 0, ['sk-mimo'])]),\n chat: response([channel('mimo-chat', 'acct-mimo', 1, ['sk-mimo'])]),\n responses: response([]),\n gemini: response([]),\n }\n const [logicalChannel] = buildUnifiedChannelsData(data).channels\n const activity = (\n channelIndex: number,\n requestCount: number,\n successCount: number,\n failureCount: number,\n ): ChannelRecentActivity => ({\n channelIndex,\n segments: {\n 4: { requestCount, successCount, failureCount, inputTokens: requestCount * 10, outputTokens: requestCount * 2 },\n },\n totalSegs: 150,\n rpm: requestCount / 15,\n tpm: requestCount * 2 / 15,\n })\n\n const [merged] = buildUnifiedRecentActivity([logicalChannel], {\n messages: [activity(0, 2, 2, 0)],\n chat: [activity(1, 3, 2, 1)],\n responses: [],\n gemini: [],\n })\n\n expect(merged.channelIndex).toBe(0)\n expect(merged.routeKind).toBe('messages')\n expect(merged.segments[4]).toEqual({\n requestCount: 5,\n successCount: 4,\n failureCount: 1,\n inputTokens: 50,\n outputTokens: 10,\n })\n expect(merged.rpm).toBeCloseTo(5 / 15)\n expect(merged.tpm).toBeCloseTo(10 / 15)\n })\n})\n\ndescribe('buildUnifiedReorderPayloads', () => {\n it('priority 使用统一列表全局位次而非各协议组内名次', () => {\n const data: Record = {\n messages: response([\n channel('a-claude', 'acct-a', 0, ['sk-a']),\n channel('b-claude', 'acct-b', 1, ['sk-b']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([channel('g-gemini', 'acct-g', 0, ['sk-g'], { serviceType: 'gemini' })]),\n }\n const unified = buildUnifiedChannelsData(data).channels\n const a = unified.find(c => c.protocolRoutes?.some(r => r.kind === 'messages' && r.index === 0))!\n const b = unified.find(c => c.protocolRoutes?.some(r => r.kind === 'messages' && r.index === 1))!\n const g = unified.find(c => c.protocolRoutes?.some(r => r.kind === 'gemini'))!\n\n // 模拟拖拽后的统一列表顺序:a, g, b\n const payloads = buildUnifiedReorderPayloads([a, g, b])\n\n // messages:a 位次 1,b 位次 3(中间隔着 gemini 渠道)\n expect(payloads.get('messages')).toEqual({ order: [0, 1], priorities: [1, 3] })\n // gemini:g 位次 2。若按组内名次编号会得到 1,刷新后 min(priority) 会把 g 提到最前\n expect(payloads.get('gemini')).toEqual({ order: [0], priorities: [2] })\n expect(payloads.has('chat')).toBe(false)\n })\n\n it('无 protocolRoutes 的渠道不产生载荷', () => {\n const payloads = buildUnifiedReorderPayloads([{ name: 'plain', index: 0 } as Channel])\n expect(payloads.size).toBe(0)\n })\n})\n"}, "ca72b990da2bf24a810046b3a1cae02138c4addb": {"t": 1785759322.6622274, "n": 12508, "h": "93df98c6aa7b26ad", "c": "import { describe, expect, it } from 'vitest'\n\nimport type { Channel, ChannelRecentActivity, ChannelsResponse } from '@/services/api'\nimport { buildUnifiedChannelsData, buildUnifiedRecentActivity, buildUnifiedReorderPayloads, resolveChannelRecoveryRoutes, type LlmChannelKind } from './unifiedChannels'\n\nconst channel = (\n name: string,\n accountUid: string,\n index: number,\n apiKeys: string[],\n overrides: Partial = {},\n): Channel => ({\n name,\n accountUid,\n channelUid: `ch-${index}`,\n providerId: 'mimo',\n autoManaged: true,\n index,\n serviceType: name.endsWith('-claude') ? 'claude' : 'openai',\n baseUrl: 'https://example.com',\n apiKeys,\n ...overrides,\n})\n\nconst response = (channels: Channel[]): ChannelsResponse => ({ channels, current: -1 })\n\ndescribe('buildUnifiedChannelsData account grouping', () => {\n it('优先按 accountUid 聚合多协议渠道,不依赖 Key 指纹', () => {\n const data: Record = {\n messages: response([channel('mimo-main-claude', 'acct-main', 0, ['sk-a'])]),\n chat: response([channel('mimo-main-chat', 'acct-main', 1, ['sk-a', 'sk-b'])]),\n responses: response([channel('mimo-main-codex', 'acct-main', 2, ['sk-b'])]),\n gemini: response([channel('mimo-main-gemini', 'acct-main', 3, ['sk-a'])]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].accountUid).toBe('acct-main')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses', 'gemini'])\n expect(result.channels[0].protocolRoutes?.map(item => item.status)).toEqual([undefined, undefined, undefined, undefined])\n })\n\n it('聚合账号合并各协议凭证并按全部路由计算状态', () => {\n const data: Record = {\n messages: response([channel('kimi-claude', 'acct-kimi', 0, [], {\n status: 'suspended',\n })]),\n chat: response([channel('kimi-chat', 'acct-kimi', 1, ['sk-kimi', 'sk-disabled'], {\n status: 'active',\n apiKeyConfigs: [{ key: 'sk-kimi', credentialUid: 'cred-kimi' }],\n disabledApiKeys: [{\n key: 'sk-disabled',\n reason: 'authentication_error',\n message: 'invalid key',\n disabledAt: '2026-07-28T00:00:00Z',\n }],\n })]),\n responses: response([]),\n gemini: response([]),\n }\n\n const [logicalChannel] = buildUnifiedChannelsData(data).channels\n expect(logicalChannel.apiKeys).toEqual(['sk-kimi', 'sk-disabled'])\n expect(logicalChannel.apiKeyConfigs).toEqual([\n { key: 'sk-kimi', credentialUid: 'cred-kimi' },\n ])\n expect(logicalChannel.disabledApiKeys).toEqual([{\n key: 'sk-disabled',\n reason: 'authentication_error',\n message: 'invalid key',\n disabledAt: '2026-07-28T00:00:00Z',\n }])\n expect(logicalChannel.status).toBe('active')\n })\n\n it('保留各协议路由状态供聚合渠道恢复使用', () => {\n const data: Record = {\n messages: response([channel('mimo-main-claude', 'acct-main', 0, ['sk-a'], { status: 'suspended' })]),\n chat: response([channel('mimo-main-chat', 'acct-main', 1, ['sk-a'], { status: 'active' })]),\n responses: response([channel('mimo-main-codex', 'acct-main', 2, ['sk-a'], { status: 'disabled' })]),\n gemini: response([]),\n }\n\n const logicalChannel = buildUnifiedChannelsData(data).channels[0]\n expect(logicalChannel.protocolRoutes?.map(route => route.status)).toEqual([\n 'suspended',\n 'active',\n 'disabled',\n ])\n expect(resolveChannelRecoveryRoutes(logicalChannel)).toEqual([\n { kind: 'messages', index: 0, status: 'suspended' },\n { kind: 'chat', index: 1, status: 'active' },\n { kind: 'responses', index: 2, status: 'disabled' },\n ])\n })\n\n\n it('相同 provider 和名称下不同 accountUid 不应合并', () => {\n const data: Record = {\n messages: response([\n channel('mimo-main-claude', 'acct-a', 0, ['sk-a']),\n channel('mimo-main-claude', 'acct-b', 1, ['sk-b']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n\n expect(buildUnifiedChannelsData(data).channels).toHaveLength(2)\n })\n\n it('按 accountUid 聚合自定义自动托管的全部成功协议', () => {\n const custom = (name: string, index: number, serviceType: Channel['serviceType']): Channel =>\n channel(name, 'acct-fastaitoken', index, ['sk-fastaitoken'], { providerId: '', autoManaged: true, serviceType })\n const data: Record = {\n messages: response([{\n ...custom('fastaitoken-com-test-claude', 0, 'claude'),\n supportedModels: ['gpt-5.6-sol', 'gpt-5.6-terra'],\n }]),\n chat: response([{\n ...custom('fastaitoken-com-test-chat', 0, 'openai'),\n supportedModels: ['gpt-5.6-sol'],\n }]),\n responses: response([{\n ...custom('fastaitoken-com-test-codex', 0, 'responses'),\n supportedModels: ['codex-auto-review', 'gpt-5.6-sol'],\n }]),\n gemini: response([]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].name).toBe('fastaitoken-com-test')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT', 'CODEX'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses'])\n expect(Object.fromEntries(\n result.channels[0].protocolRoutes?.map(route => [route.kind, route.supportedModels]) ?? [],\n )).toEqual({\n messages: ['gpt-5.6-sol', 'gpt-5.6-terra'],\n chat: ['gpt-5.6-sol'],\n responses: ['codex-auto-review', 'gpt-5.6-sol'],\n })\n })\n\n it('协议标签只展示上游实际提供的 serviceType', () => {\n const data: Record = {\n messages: response([channel('volcengine-claude', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'claude',\n })]),\n chat: response([channel('volcengine-chat', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n responses: response([channel('volcengine-codex', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n gemini: response([channel('volcengine-gemini', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n }\n\n const [volcengine] = buildUnifiedChannelsData(data).channels\n expect(volcengine.protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(volcengine.protocolRoutes).toHaveLength(4)\n expect(volcengine.protocolRoutes?.every(route => route.supportedModels === undefined)).toBe(true)\n })\n\n it('新增单协议渠道置顶后保持多协议账号的既有相对顺序', () => {\n const data: Record = {\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '', priority: 0 }),\n channel('volcengine-claude', 'acct-volcengine', 1, ['sk-volcengine'], { providerId: 'volcengine', priority: 1 }),\n channel('mimo-claude', 'acct-mimo', 2, ['sk-mimo'], { priority: 2 }),\n ]),\n chat: response([\n channel('volcengine-chat', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-chat', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('desktop-deepseek-chat', 'acct-deepseek', 34, ['sk-deepseek'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n responses: response([\n channel('volcengine-codex', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-codex', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('aixoras-xanqfm', 'acct-aixoras', 2, ['sk-aixoras'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n gemini: response([\n channel('volcengine-gemini', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-gemini', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n ]),\n }\n\n const channels = buildUnifiedChannelsData(data).channels\n const sorted = [...channels].sort((a, b) => (a.priority ?? a.index) - (b.priority ?? b.index))\n\n expect(sorted.slice(0, 5).map(item => item.name)).toEqual([\n 'localhost-37zq4d',\n 'volcengine',\n 'mimo',\n 'desktop-deepseek-chat',\n 'aixoras-xanqfm',\n ])\n expect(channels.find(item => item.name === 'mimo')?.priority).toBe(1)\n })\n\n it('在列表头部插入渠道时保持既有逻辑渠道的展示 key 稳定', () => {\n const original: Record = {\n messages: response([\n channel('volcengine-claude', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine' }),\n channel('mimo-claude', 'acct-mimo', 1, ['sk-mimo']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n const withNewChannel: Record = {\n ...original,\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '' }),\n ...original.messages.channels,\n ]),\n }\n\n const originalMimo = buildUnifiedChannelsData(original).channels.find(item => item.name === 'mimo')\n const nextMimo = buildUnifiedChannelsData(withNewChannel).channels.find(item => item.name === 'mimo')\n\n expect(nextMimo?.displayKey).toBe(originalMimo?.displayKey)\n })\n\n it('聚合逻辑渠道全部协议的最近请求活动', () => {\n const data: Record = {\n messages: response([channel('mimo-claude', 'acct-mimo', 0, ['sk-mimo'])]),\n chat: response([channel('mimo-chat', 'acct-mimo', 1, ['sk-mimo'])]),\n responses: response([]),\n gemini: response([]),\n }\n const [logicalChannel] = buildUnifiedChannelsData(data).channels\n const activity = (\n channelIndex: number,\n requestCount: number,\n successCount: number,\n failureCount: number,\n ): ChannelRecentActivity => ({\n channelIndex,\n segments: {\n 4: { requestCount, successCount, failureCount, inputTokens: requestCount * 10, outputTokens: requestCount * 2 },\n },\n totalSegs: 150,\n rpm: requestCount / 15,\n tpm: requestCount * 2 / 15,\n })\n\n const [merged] = buildUnifiedRecentActivity([logicalChannel], {\n messages: [activity(0, 2, 2, 0)],\n chat: [activity(1, 3, 2, 1)],\n responses: [],\n gemini: [],\n })\n\n expect(merged.channelIndex).toBe(0)\n expect(merged.routeKind).toBe('messages')\n expect(merged.segments[4]).toEqual({\n requestCount: 5,\n successCount: 4,\n failureCount: 1,\n inputTokens: 50,\n outputTokens: 10,\n })\n expect(merged.rpm).toBeCloseTo(5 / 15)\n expect(merged.tpm).toBeCloseTo(10 / 15)\n })\n})\n\n\ndescribe('buildUnifiedReorderPayloads', () => {\n it('priority 使用统一列表全局位次而非各协议组内名次', () => {\n const data: Record = {\n messages: response([\n channel('a-claude', 'acct-a', 0, ['sk-a']),\n channel('b-claude', 'acct-b', 1, ['sk-b']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([channel('g-gemini', 'acct-g', 0, ['sk-g'], { serviceType: 'gemini' })]),\n }\n const unified = buildUnifiedChannelsData(data).channels\n const a = unified.find(c => c.protocolRoutes?.some(r => r.kind === 'messages' && r.index === 0))!\n const b = unified.find(c => c.protocolRoutes?.some(r => r.kind === 'messages' && r.index === 1))!\n const g = unified.find(c => c.protocolRoutes?.some(r => r.kind === 'gemini'))!\n\n // 模拟拖拽后的统一列表顺序:a, g, b\n const payloads = buildUnifiedReorderPayloads([a, g, b])\n\n // messages:a 位次 1,b 位次 3(中间隔着 gemini 渠道)\n expect(payloads.get('messages')).toEqual({ order: [0, 1], priorities: [1, 3] })\n // gemini:g 位次 2。若按组内名次编号会得到 1,刷新后 min(priority) 会把 g 提到最前\n expect(payloads.get('gemini')).toEqual({ order: [0], priorities: [2] })\n expect(payloads.has('chat')).toBe(false)\n })\n\n it('无 protocolRoutes 的渠道不产生载荷', () => {\n const payloads = buildUnifiedReorderPayloads([{ name: 'plain', index: 0 } as Channel])\n expect(payloads.size).toBe(0)\n })\n})\n"}, "f6acaa1dab775b6ddbb4b3627a9c6bed3e1cd7d6": {"t": 1785759324.2963176, "n": 11090, "h": "dd3825d945636deb", "c": "import { describe, expect, it } from 'vitest'\n\nimport type { Channel, ChannelRecentActivity, ChannelsResponse } from '@/services/api'\nimport { buildUnifiedChannelsData, buildUnifiedRecentActivity, resolveChannelRecoveryRoutes, type LlmChannelKind } from './unifiedChannels'\n\nconst channel = (\n name: string,\n accountUid: string,\n index: number,\n apiKeys: string[],\n overrides: Partial = {},\n): Channel => ({\n name,\n accountUid,\n channelUid: `ch-${index}`,\n providerId: 'mimo',\n autoManaged: true,\n index,\n serviceType: name.endsWith('-claude') ? 'claude' : 'openai',\n baseUrl: 'https://example.com',\n apiKeys,\n ...overrides,\n})\n\nconst response = (channels: Channel[]): ChannelsResponse => ({ channels, current: -1 })\n\ndescribe('buildUnifiedChannelsData account grouping', () => {\n it('优先按 accountUid 聚合多协议渠道,不依赖 Key 指纹', () => {\n const data: Record = {\n messages: response([channel('mimo-main-claude', 'acct-main', 0, ['sk-a'])]),\n chat: response([channel('mimo-main-chat', 'acct-main', 1, ['sk-a', 'sk-b'])]),\n responses: response([channel('mimo-main-codex', 'acct-main', 2, ['sk-b'])]),\n gemini: response([channel('mimo-main-gemini', 'acct-main', 3, ['sk-a'])]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].accountUid).toBe('acct-main')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses', 'gemini'])\n expect(result.channels[0].protocolRoutes?.map(item => item.status)).toEqual([undefined, undefined, undefined, undefined])\n })\n\n it('聚合账号合并各协议凭证并按全部路由计算状态', () => {\n const data: Record = {\n messages: response([channel('kimi-claude', 'acct-kimi', 0, [], {\n status: 'suspended',\n })]),\n chat: response([channel('kimi-chat', 'acct-kimi', 1, ['sk-kimi', 'sk-disabled'], {\n status: 'active',\n apiKeyConfigs: [{ key: 'sk-kimi', credentialUid: 'cred-kimi' }],\n disabledApiKeys: [{\n key: 'sk-disabled',\n reason: 'authentication_error',\n message: 'invalid key',\n disabledAt: '2026-07-28T00:00:00Z',\n }],\n })]),\n responses: response([]),\n gemini: response([]),\n }\n\n const [logicalChannel] = buildUnifiedChannelsData(data).channels\n expect(logicalChannel.apiKeys).toEqual(['sk-kimi', 'sk-disabled'])\n expect(logicalChannel.apiKeyConfigs).toEqual([\n { key: 'sk-kimi', credentialUid: 'cred-kimi' },\n ])\n expect(logicalChannel.disabledApiKeys).toEqual([{\n key: 'sk-disabled',\n reason: 'authentication_error',\n message: 'invalid key',\n disabledAt: '2026-07-28T00:00:00Z',\n }])\n expect(logicalChannel.status).toBe('active')\n })\n\n it('保留各协议路由状态供聚合渠道恢复使用', () => {\n const data: Record = {\n messages: response([channel('mimo-main-claude', 'acct-main', 0, ['sk-a'], { status: 'suspended' })]),\n chat: response([channel('mimo-main-chat', 'acct-main', 1, ['sk-a'], { status: 'active' })]),\n responses: response([channel('mimo-main-codex', 'acct-main', 2, ['sk-a'], { status: 'disabled' })]),\n gemini: response([]),\n }\n\n const logicalChannel = buildUnifiedChannelsData(data).channels[0]\n expect(logicalChannel.protocolRoutes?.map(route => route.status)).toEqual([\n 'suspended',\n 'active',\n 'disabled',\n ])\n expect(resolveChannelRecoveryRoutes(logicalChannel)).toEqual([\n { kind: 'messages', index: 0, status: 'suspended' },\n { kind: 'chat', index: 1, status: 'active' },\n { kind: 'responses', index: 2, status: 'disabled' },\n ])\n })\n\n\n it('相同 provider 和名称下不同 accountUid 不应合并', () => {\n const data: Record = {\n messages: response([\n channel('mimo-main-claude', 'acct-a', 0, ['sk-a']),\n channel('mimo-main-claude', 'acct-b', 1, ['sk-b']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n\n expect(buildUnifiedChannelsData(data).channels).toHaveLength(2)\n })\n\n it('按 accountUid 聚合自定义自动托管的全部成功协议', () => {\n const custom = (name: string, index: number, serviceType: Channel['serviceType']): Channel =>\n channel(name, 'acct-fastaitoken', index, ['sk-fastaitoken'], { providerId: '', autoManaged: true, serviceType })\n const data: Record = {\n messages: response([{\n ...custom('fastaitoken-com-test-claude', 0, 'claude'),\n supportedModels: ['gpt-5.6-sol', 'gpt-5.6-terra'],\n }]),\n chat: response([{\n ...custom('fastaitoken-com-test-chat', 0, 'openai'),\n supportedModels: ['gpt-5.6-sol'],\n }]),\n responses: response([{\n ...custom('fastaitoken-com-test-codex', 0, 'responses'),\n supportedModels: ['codex-auto-review', 'gpt-5.6-sol'],\n }]),\n gemini: response([]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].name).toBe('fastaitoken-com-test')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT', 'CODEX'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses'])\n expect(Object.fromEntries(\n result.channels[0].protocolRoutes?.map(route => [route.kind, route.supportedModels]) ?? [],\n )).toEqual({\n messages: ['gpt-5.6-sol', 'gpt-5.6-terra'],\n chat: ['gpt-5.6-sol'],\n responses: ['codex-auto-review', 'gpt-5.6-sol'],\n })\n })\n\n it('协议标签只展示上游实际提供的 serviceType', () => {\n const data: Record = {\n messages: response([channel('volcengine-claude', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'claude',\n })]),\n chat: response([channel('volcengine-chat', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n responses: response([channel('volcengine-codex', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n gemini: response([channel('volcengine-gemini', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n }\n\n const [volcengine] = buildUnifiedChannelsData(data).channels\n expect(volcengine.protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(volcengine.protocolRoutes).toHaveLength(4)\n expect(volcengine.protocolRoutes?.every(route => route.supportedModels === undefined)).toBe(true)\n })\n\n it('新增单协议渠道置顶后保持多协议账号的既有相对顺序', () => {\n const data: Record = {\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '', priority: 0 }),\n channel('volcengine-claude', 'acct-volcengine', 1, ['sk-volcengine'], { providerId: 'volcengine', priority: 1 }),\n channel('mimo-claude', 'acct-mimo', 2, ['sk-mimo'], { priority: 2 }),\n ]),\n chat: response([\n channel('volcengine-chat', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-chat', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('desktop-deepseek-chat', 'acct-deepseek', 34, ['sk-deepseek'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n responses: response([\n channel('volcengine-codex', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-codex', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('aixoras-xanqfm', 'acct-aixoras', 2, ['sk-aixoras'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n gemini: response([\n channel('volcengine-gemini', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-gemini', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n ]),\n }\n\n const channels = buildUnifiedChannelsData(data).channels\n const sorted = [...channels].sort((a, b) => (a.priority ?? a.index) - (b.priority ?? b.index))\n\n expect(sorted.slice(0, 5).map(item => item.name)).toEqual([\n 'localhost-37zq4d',\n 'volcengine',\n 'mimo',\n 'desktop-deepseek-chat',\n 'aixoras-xanqfm',\n ])\n expect(channels.find(item => item.name === 'mimo')?.priority).toBe(1)\n })\n\n it('在列表头部插入渠道时保持既有逻辑渠道的展示 key 稳定', () => {\n const original: Record = {\n messages: response([\n channel('volcengine-claude', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine' }),\n channel('mimo-claude', 'acct-mimo', 1, ['sk-mimo']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n const withNewChannel: Record = {\n ...original,\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '' }),\n ...original.messages.channels,\n ]),\n }\n\n const originalMimo = buildUnifiedChannelsData(original).channels.find(item => item.name === 'mimo')\n const nextMimo = buildUnifiedChannelsData(withNewChannel).channels.find(item => item.name === 'mimo')\n\n expect(nextMimo?.displayKey).toBe(originalMimo?.displayKey)\n })\n\n it('聚合逻辑渠道全部协议的最近请求活动', () => {\n const data: Record = {\n messages: response([channel('mimo-claude', 'acct-mimo', 0, ['sk-mimo'])]),\n chat: response([channel('mimo-chat', 'acct-mimo', 1, ['sk-mimo'])]),\n responses: response([]),\n gemini: response([]),\n }\n const [logicalChannel] = buildUnifiedChannelsData(data).channels\n const activity = (\n channelIndex: number,\n requestCount: number,\n successCount: number,\n failureCount: number,\n ): ChannelRecentActivity => ({\n channelIndex,\n segments: {\n 4: { requestCount, successCount, failureCount, inputTokens: requestCount * 10, outputTokens: requestCount * 2 },\n },\n totalSegs: 150,\n rpm: requestCount / 15,\n tpm: requestCount * 2 / 15,\n })\n\n const [merged] = buildUnifiedRecentActivity([logicalChannel], {\n messages: [activity(0, 2, 2, 0)],\n chat: [activity(1, 3, 2, 1)],\n responses: [],\n gemini: [],\n })\n\n expect(merged.channelIndex).toBe(0)\n expect(merged.routeKind).toBe('messages')\n expect(merged.segments[4]).toEqual({\n requestCount: 5,\n successCount: 4,\n failureCount: 1,\n inputTokens: 50,\n outputTokens: 10,\n })\n expect(merged.rpm).toBeCloseTo(5 / 15)\n expect(merged.tpm).toBeCloseTo(10 / 15)\n })\n})\n"}, "a811d0ff64c52ebdd09e5ae36302af04c1a08b50": {"t": 1785759326.244432, "n": 9915, "h": "0bb36189c8ef44b3", "c": "import { describe, expect, it } from 'vitest'\n\nimport type { Channel, ChannelRecentActivity, ChannelsResponse } from '@/services/api'\nimport { buildUnifiedChannelsData, buildUnifiedRecentActivity, resolveChannelRecoveryRoutes, type LlmChannelKind } from './unifiedChannels'\n\nconst channel = (\n name: string,\n accountUid: string,\n index: number,\n apiKeys: string[],\n overrides: Partial = {},\n): Channel => ({\n name,\n accountUid,\n channelUid: `ch-${index}`,\n providerId: 'mimo',\n autoManaged: true,\n index,\n serviceType: name.endsWith('-claude') ? 'claude' : 'openai',\n baseUrl: 'https://example.com',\n apiKeys,\n ...overrides,\n})\n\nconst response = (channels: Channel[]): ChannelsResponse => ({ channels, current: -1 })\n\ndescribe('buildUnifiedChannelsData account grouping', () => {\n it('优先按 accountUid 聚合多协议渠道,不依赖 Key 指纹', () => {\n const data: Record = {\n messages: response([channel('mimo-main-claude', 'acct-main', 0, ['sk-a'])]),\n chat: response([channel('mimo-main-chat', 'acct-main', 1, ['sk-a', 'sk-b'])]),\n responses: response([channel('mimo-main-codex', 'acct-main', 2, ['sk-b'])]),\n gemini: response([channel('mimo-main-gemini', 'acct-main', 3, ['sk-a'])]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].accountUid).toBe('acct-main')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses', 'gemini'])\n expect(result.channels[0].protocolRoutes?.map(item => item.status)).toEqual([undefined, undefined, undefined, undefined])\n })\n\n it('保留各协议路由状态供聚合渠道恢复使用', () => {\n const data: Record = {\n messages: response([channel('mimo-main-claude', 'acct-main', 0, ['sk-a'], { status: 'suspended' })]),\n chat: response([channel('mimo-main-chat', 'acct-main', 1, ['sk-a'], { status: 'active' })]),\n responses: response([channel('mimo-main-codex', 'acct-main', 2, ['sk-a'], { status: 'disabled' })]),\n gemini: response([]),\n }\n\n const logicalChannel = buildUnifiedChannelsData(data).channels[0]\n expect(logicalChannel.protocolRoutes?.map(route => route.status)).toEqual([\n 'suspended',\n 'active',\n 'disabled',\n ])\n expect(resolveChannelRecoveryRoutes(logicalChannel)).toEqual([\n { kind: 'messages', index: 0, status: 'suspended' },\n { kind: 'chat', index: 1, status: 'active' },\n { kind: 'responses', index: 2, status: 'disabled' },\n ])\n })\n\n it('相同 provider 和名称下不同 accountUid 不应合并', () => {\n const data: Record = {\n messages: response([\n channel('mimo-main-claude', 'acct-a', 0, ['sk-a']),\n channel('mimo-main-claude', 'acct-b', 1, ['sk-b']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n\n expect(buildUnifiedChannelsData(data).channels).toHaveLength(2)\n })\n\n it('按 accountUid 聚合自定义自动托管的全部成功协议', () => {\n const custom = (name: string, index: number, serviceType: Channel['serviceType']): Channel =>\n channel(name, 'acct-fastaitoken', index, ['sk-fastaitoken'], { providerId: '', autoManaged: true, serviceType })\n const data: Record = {\n messages: response([{\n ...custom('fastaitoken-com-test-claude', 0, 'claude'),\n supportedModels: ['gpt-5.6-sol', 'gpt-5.6-terra'],\n }]),\n chat: response([{\n ...custom('fastaitoken-com-test-chat', 0, 'openai'),\n supportedModels: ['gpt-5.6-sol'],\n }]),\n responses: response([{\n ...custom('fastaitoken-com-test-codex', 0, 'responses'),\n supportedModels: ['codex-auto-review', 'gpt-5.6-sol'],\n }]),\n gemini: response([]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].name).toBe('fastaitoken-com-test')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT', 'CODEX'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses'])\n expect(Object.fromEntries(\n result.channels[0].protocolRoutes?.map(route => [route.kind, route.supportedModels]) ?? [],\n )).toEqual({\n messages: ['gpt-5.6-sol', 'gpt-5.6-terra'],\n chat: ['gpt-5.6-sol'],\n responses: ['codex-auto-review', 'gpt-5.6-sol'],\n })\n })\n\n it('协议标签只展示上游实际提供的 serviceType', () => {\n const data: Record = {\n messages: response([channel('volcengine-claude', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'claude',\n })]),\n chat: response([channel('volcengine-chat', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n responses: response([channel('volcengine-codex', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n gemini: response([channel('volcengine-gemini', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n }\n\n const [volcengine] = buildUnifiedChannelsData(data).channels\n expect(volcengine.protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(volcengine.protocolRoutes).toHaveLength(4)\n expect(volcengine.protocolRoutes?.every(route => route.supportedModels === undefined)).toBe(true)\n })\n\n it('新增单协议渠道置顶后保持多协议账号的既有相对顺序', () => {\n const data: Record = {\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '', priority: 0 }),\n channel('volcengine-claude', 'acct-volcengine', 1, ['sk-volcengine'], { providerId: 'volcengine', priority: 1 }),\n channel('mimo-claude', 'acct-mimo', 2, ['sk-mimo'], { priority: 2 }),\n ]),\n chat: response([\n channel('volcengine-chat', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-chat', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('desktop-deepseek-chat', 'acct-deepseek', 34, ['sk-deepseek'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n responses: response([\n channel('volcengine-codex', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-codex', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('aixoras-xanqfm', 'acct-aixoras', 2, ['sk-aixoras'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n gemini: response([\n channel('volcengine-gemini', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-gemini', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n ]),\n }\n\n const channels = buildUnifiedChannelsData(data).channels\n const sorted = [...channels].sort((a, b) => (a.priority ?? a.index) - (b.priority ?? b.index))\n\n expect(sorted.slice(0, 5).map(item => item.name)).toEqual([\n 'localhost-37zq4d',\n 'volcengine',\n 'mimo',\n 'desktop-deepseek-chat',\n 'aixoras-xanqfm',\n ])\n expect(channels.find(item => item.name === 'mimo')?.priority).toBe(1)\n })\n\n it('在列表头部插入渠道时保持既有逻辑渠道的展示 key 稳定', () => {\n const original: Record = {\n messages: response([\n channel('volcengine-claude', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine' }),\n channel('mimo-claude', 'acct-mimo', 1, ['sk-mimo']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n const withNewChannel: Record = {\n ...original,\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '' }),\n ...original.messages.channels,\n ]),\n }\n\n const originalMimo = buildUnifiedChannelsData(original).channels.find(item => item.name === 'mimo')\n const nextMimo = buildUnifiedChannelsData(withNewChannel).channels.find(item => item.name === 'mimo')\n\n expect(nextMimo?.displayKey).toBe(originalMimo?.displayKey)\n })\n\n it('聚合逻辑渠道全部协议的最近请求活动', () => {\n const data: Record = {\n messages: response([channel('mimo-claude', 'acct-mimo', 0, ['sk-mimo'])]),\n chat: response([channel('mimo-chat', 'acct-mimo', 1, ['sk-mimo'])]),\n responses: response([]),\n gemini: response([]),\n }\n const [logicalChannel] = buildUnifiedChannelsData(data).channels\n const activity = (\n channelIndex: number,\n requestCount: number,\n successCount: number,\n failureCount: number,\n ): ChannelRecentActivity => ({\n channelIndex,\n segments: {\n 4: { requestCount, successCount, failureCount, inputTokens: requestCount * 10, outputTokens: requestCount * 2 },\n },\n totalSegs: 150,\n rpm: requestCount / 15,\n tpm: requestCount * 2 / 15,\n })\n\n const [merged] = buildUnifiedRecentActivity([logicalChannel], {\n messages: [activity(0, 2, 2, 0)],\n chat: [activity(1, 3, 2, 1)],\n responses: [],\n gemini: [],\n })\n\n expect(merged.channelIndex).toBe(0)\n expect(merged.routeKind).toBe('messages')\n expect(merged.segments[4]).toEqual({\n requestCount: 5,\n successCount: 4,\n failureCount: 1,\n inputTokens: 50,\n outputTokens: 10,\n })\n expect(merged.rpm).toBeCloseTo(5 / 15)\n expect(merged.tpm).toBeCloseTo(10 / 15)\n })\n})\n"}, "c48015b8e2d5a892e694abfe405666e082f03231": {"t": 1785759327.6833618, "n": 8854, "h": "e584fc9d43caa169", "c": "import { describe, expect, it } from 'vitest'\n\nimport type { Channel, ChannelRecentActivity, ChannelsResponse } from '@/services/api'\nimport { buildUnifiedChannelsData, buildUnifiedRecentActivity, type LlmChannelKind } from './unifiedChannels'\n\nconst channel = (\n name: string,\n accountUid: string,\n index: number,\n apiKeys: string[],\n overrides: Partial = {},\n): Channel => ({\n name,\n accountUid,\n channelUid: `ch-${index}`,\n providerId: 'mimo',\n autoManaged: true,\n index,\n serviceType: name.endsWith('-claude') ? 'claude' : 'openai',\n baseUrl: 'https://example.com',\n apiKeys,\n ...overrides,\n})\n\nconst response = (channels: Channel[]): ChannelsResponse => ({ channels, current: -1 })\n\ndescribe('buildUnifiedChannelsData account grouping', () => {\n it('优先按 accountUid 聚合多协议渠道,不依赖 Key 指纹', () => {\n const data: Record = {\n messages: response([channel('mimo-main-claude', 'acct-main', 0, ['sk-a'])]),\n chat: response([channel('mimo-main-chat', 'acct-main', 1, ['sk-a', 'sk-b'])]),\n responses: response([channel('mimo-main-codex', 'acct-main', 2, ['sk-b'])]),\n gemini: response([channel('mimo-main-gemini', 'acct-main', 3, ['sk-a'])]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].accountUid).toBe('acct-main')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses', 'gemini'])\n })\n\n it('相同 provider 和名称下不同 accountUid 不应合并', () => {\n const data: Record = {\n messages: response([\n channel('mimo-main-claude', 'acct-a', 0, ['sk-a']),\n channel('mimo-main-claude', 'acct-b', 1, ['sk-b']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n\n expect(buildUnifiedChannelsData(data).channels).toHaveLength(2)\n })\n\n it('按 accountUid 聚合自定义自动托管的全部成功协议', () => {\n const custom = (name: string, index: number, serviceType: Channel['serviceType']): Channel =>\n channel(name, 'acct-fastaitoken', index, ['sk-fastaitoken'], { providerId: '', autoManaged: true, serviceType })\n const data: Record = {\n messages: response([{\n ...custom('fastaitoken-com-test-claude', 0, 'claude'),\n supportedModels: ['gpt-5.6-sol', 'gpt-5.6-terra'],\n }]),\n chat: response([{\n ...custom('fastaitoken-com-test-chat', 0, 'openai'),\n supportedModels: ['gpt-5.6-sol'],\n }]),\n responses: response([{\n ...custom('fastaitoken-com-test-codex', 0, 'responses'),\n supportedModels: ['codex-auto-review', 'gpt-5.6-sol'],\n }]),\n gemini: response([]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].name).toBe('fastaitoken-com-test')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT', 'CODEX'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses'])\n expect(Object.fromEntries(\n result.channels[0].protocolRoutes?.map(route => [route.kind, route.supportedModels]) ?? [],\n )).toEqual({\n messages: ['gpt-5.6-sol', 'gpt-5.6-terra'],\n chat: ['gpt-5.6-sol'],\n responses: ['codex-auto-review', 'gpt-5.6-sol'],\n })\n })\n\n it('协议标签只展示上游实际提供的 serviceType', () => {\n const data: Record = {\n messages: response([channel('volcengine-claude', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'claude',\n })]),\n chat: response([channel('volcengine-chat', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n responses: response([channel('volcengine-codex', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n gemini: response([channel('volcengine-gemini', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n }\n\n const [volcengine] = buildUnifiedChannelsData(data).channels\n expect(volcengine.protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(volcengine.protocolRoutes).toHaveLength(4)\n expect(volcengine.protocolRoutes?.every(route => route.supportedModels === undefined)).toBe(true)\n })\n\n it('新增单协议渠道置顶后保持多协议账号的既有相对顺序', () => {\n const data: Record = {\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '', priority: 0 }),\n channel('volcengine-claude', 'acct-volcengine', 1, ['sk-volcengine'], { providerId: 'volcengine', priority: 1 }),\n channel('mimo-claude', 'acct-mimo', 2, ['sk-mimo'], { priority: 2 }),\n ]),\n chat: response([\n channel('volcengine-chat', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-chat', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('desktop-deepseek-chat', 'acct-deepseek', 34, ['sk-deepseek'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n responses: response([\n channel('volcengine-codex', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-codex', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('aixoras-xanqfm', 'acct-aixoras', 2, ['sk-aixoras'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n gemini: response([\n channel('volcengine-gemini', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-gemini', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n ]),\n }\n\n const channels = buildUnifiedChannelsData(data).channels\n const sorted = [...channels].sort((a, b) => (a.priority ?? a.index) - (b.priority ?? b.index))\n\n expect(sorted.slice(0, 5).map(item => item.name)).toEqual([\n 'localhost-37zq4d',\n 'volcengine',\n 'mimo',\n 'desktop-deepseek-chat',\n 'aixoras-xanqfm',\n ])\n expect(channels.find(item => item.name === 'mimo')?.priority).toBe(1)\n })\n\n it('在列表头部插入渠道时保持既有逻辑渠道的展示 key 稳定', () => {\n const original: Record = {\n messages: response([\n channel('volcengine-claude', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine' }),\n channel('mimo-claude', 'acct-mimo', 1, ['sk-mimo']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n const withNewChannel: Record = {\n ...original,\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '' }),\n ...original.messages.channels,\n ]),\n }\n\n const originalMimo = buildUnifiedChannelsData(original).channels.find(item => item.name === 'mimo')\n const nextMimo = buildUnifiedChannelsData(withNewChannel).channels.find(item => item.name === 'mimo')\n\n expect(nextMimo?.displayKey).toBe(originalMimo?.displayKey)\n })\n\n it('聚合逻辑渠道全部协议的最近请求活动', () => {\n const data: Record = {\n messages: response([channel('mimo-claude', 'acct-mimo', 0, ['sk-mimo'])]),\n chat: response([channel('mimo-chat', 'acct-mimo', 1, ['sk-mimo'])]),\n responses: response([]),\n gemini: response([]),\n }\n const [logicalChannel] = buildUnifiedChannelsData(data).channels\n const activity = (\n channelIndex: number,\n requestCount: number,\n successCount: number,\n failureCount: number,\n ): ChannelRecentActivity => ({\n channelIndex,\n segments: {\n 4: { requestCount, successCount, failureCount, inputTokens: requestCount * 10, outputTokens: requestCount * 2 },\n },\n totalSegs: 150,\n rpm: requestCount / 15,\n tpm: requestCount * 2 / 15,\n })\n\n const [merged] = buildUnifiedRecentActivity([logicalChannel], {\n messages: [activity(0, 2, 2, 0)],\n chat: [activity(1, 3, 2, 1)],\n responses: [],\n gemini: [],\n })\n\n expect(merged.channelIndex).toBe(0)\n expect(merged.routeKind).toBe('messages')\n expect(merged.segments[4]).toEqual({\n requestCount: 5,\n successCount: 4,\n failureCount: 1,\n inputTokens: 50,\n outputTokens: 10,\n })\n expect(merged.rpm).toBeCloseTo(5 / 15)\n expect(merged.tpm).toBeCloseTo(10 / 15)\n })\n})\n"}, "db29381c7eccb78e1c57ec4045d3f460a10c4e8c": {"t": 1785759329.1467557, "n": 8752, "h": "52758c1f6b30d71c", "c": "import { describe, expect, it } from 'vitest'\n\nimport type { Channel, ChannelRecentActivity, ChannelsResponse } from '@/services/api'\nimport { buildUnifiedChannelsData, buildUnifiedRecentActivity, type LlmChannelKind } from './unifiedChannels'\n\nconst channel = (\n name: string,\n accountUid: string,\n index: number,\n apiKeys: string[],\n overrides: Partial = {},\n): Channel => ({\n name,\n accountUid,\n channelUid: `ch-${index}`,\n providerId: 'mimo',\n autoManaged: true,\n index,\n serviceType: name.endsWith('-claude') ? 'claude' : 'openai',\n baseUrl: 'https://example.com',\n apiKeys,\n ...overrides,\n})\n\nconst response = (channels: Channel[]): ChannelsResponse => ({ channels, current: -1 })\n\ndescribe('buildUnifiedChannelsData account grouping', () => {\n it('优先按 accountUid 聚合多协议渠道,不依赖 Key 指纹', () => {\n const data: Record = {\n messages: response([channel('mimo-main-claude', 'acct-main', 0, ['sk-a'])]),\n chat: response([channel('mimo-main-chat', 'acct-main', 1, ['sk-a', 'sk-b'])]),\n responses: response([channel('mimo-main-codex', 'acct-main', 2, ['sk-b'])]),\n gemini: response([channel('mimo-main-gemini', 'acct-main', 3, ['sk-a'])]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].accountUid).toBe('acct-main')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses', 'gemini'])\n })\n\n it('相同 provider 和名称下不同 accountUid 不应合并', () => {\n const data: Record = {\n messages: response([\n channel('mimo-main-claude', 'acct-a', 0, ['sk-a']),\n channel('mimo-main-claude', 'acct-b', 1, ['sk-b']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n\n expect(buildUnifiedChannelsData(data).channels).toHaveLength(2)\n })\n\n it('按 accountUid 聚合自定义自动托管的全部成功协议', () => {\n const custom = (name: string, index: number, serviceType: Channel['serviceType']): Channel =>\n channel(name, 'acct-fastaitoken', index, ['sk-fastaitoken'], { providerId: '', autoManaged: true, serviceType })\n const data: Record = {\n messages: response([{\n ...custom('fastaitoken-com-test-claude', 0, 'claude'),\n supportedModels: ['gpt-5.6-sol', 'gpt-5.6-terra'],\n }]),\n chat: response([{\n ...custom('fastaitoken-com-test-chat', 0, 'openai'),\n supportedModels: ['gpt-5.6-sol'],\n }]),\n responses: response([{\n ...custom('fastaitoken-com-test-codex', 0, 'responses'),\n supportedModels: ['codex-auto-review', 'gpt-5.6-sol'],\n }]),\n gemini: response([]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].name).toBe('fastaitoken-com-test')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT', 'CODEX'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses'])\n expect(Object.fromEntries(\n result.channels[0].protocolRoutes?.map(route => [route.kind, route.supportedModels]) ?? [],\n )).toEqual({\n messages: ['gpt-5.6-sol', 'gpt-5.6-terra'],\n chat: ['gpt-5.6-sol'],\n responses: ['codex-auto-review', 'gpt-5.6-sol'],\n })\n })\n\n it('协议标签只展示上游实际提供的 serviceType', () => {\n const data: Record = {\n messages: response([channel('volcengine-claude', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'claude',\n })]),\n chat: response([channel('volcengine-chat', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n responses: response([channel('volcengine-codex', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n gemini: response([channel('volcengine-gemini', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n }\n\n const [volcengine] = buildUnifiedChannelsData(data).channels\n expect(volcengine.protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(volcengine.protocolRoutes).toHaveLength(4)\n })\n\n it('新增单协议渠道置顶后保持多协议账号的既有相对顺序', () => {\n const data: Record = {\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '', priority: 0 }),\n channel('volcengine-claude', 'acct-volcengine', 1, ['sk-volcengine'], { providerId: 'volcengine', priority: 1 }),\n channel('mimo-claude', 'acct-mimo', 2, ['sk-mimo'], { priority: 2 }),\n ]),\n chat: response([\n channel('volcengine-chat', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-chat', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('desktop-deepseek-chat', 'acct-deepseek', 34, ['sk-deepseek'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n responses: response([\n channel('volcengine-codex', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-codex', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('aixoras-xanqfm', 'acct-aixoras', 2, ['sk-aixoras'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n gemini: response([\n channel('volcengine-gemini', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-gemini', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n ]),\n }\n\n const channels = buildUnifiedChannelsData(data).channels\n const sorted = [...channels].sort((a, b) => (a.priority ?? a.index) - (b.priority ?? b.index))\n\n expect(sorted.slice(0, 5).map(item => item.name)).toEqual([\n 'localhost-37zq4d',\n 'volcengine',\n 'mimo',\n 'desktop-deepseek-chat',\n 'aixoras-xanqfm',\n ])\n expect(channels.find(item => item.name === 'mimo')?.priority).toBe(1)\n })\n\n it('在列表头部插入渠道时保持既有逻辑渠道的展示 key 稳定', () => {\n const original: Record = {\n messages: response([\n channel('volcengine-claude', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine' }),\n channel('mimo-claude', 'acct-mimo', 1, ['sk-mimo']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n const withNewChannel: Record = {\n ...original,\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '' }),\n ...original.messages.channels,\n ]),\n }\n\n const originalMimo = buildUnifiedChannelsData(original).channels.find(item => item.name === 'mimo')\n const nextMimo = buildUnifiedChannelsData(withNewChannel).channels.find(item => item.name === 'mimo')\n\n expect(nextMimo?.displayKey).toBe(originalMimo?.displayKey)\n })\n\n it('聚合逻辑渠道全部协议的最近请求活动', () => {\n const data: Record = {\n messages: response([channel('mimo-claude', 'acct-mimo', 0, ['sk-mimo'])]),\n chat: response([channel('mimo-chat', 'acct-mimo', 1, ['sk-mimo'])]),\n responses: response([]),\n gemini: response([]),\n }\n const [logicalChannel] = buildUnifiedChannelsData(data).channels\n const activity = (\n channelIndex: number,\n requestCount: number,\n successCount: number,\n failureCount: number,\n ): ChannelRecentActivity => ({\n channelIndex,\n segments: {\n 4: { requestCount, successCount, failureCount, inputTokens: requestCount * 10, outputTokens: requestCount * 2 },\n },\n totalSegs: 150,\n rpm: requestCount / 15,\n tpm: requestCount * 2 / 15,\n })\n\n const [merged] = buildUnifiedRecentActivity([logicalChannel], {\n messages: [activity(0, 2, 2, 0)],\n chat: [activity(1, 3, 2, 1)],\n responses: [],\n gemini: [],\n })\n\n expect(merged.channelIndex).toBe(0)\n expect(merged.routeKind).toBe('messages')\n expect(merged.segments[4]).toEqual({\n requestCount: 5,\n successCount: 4,\n failureCount: 1,\n inputTokens: 50,\n outputTokens: 10,\n })\n expect(merged.rpm).toBeCloseTo(5 / 15)\n expect(merged.tpm).toBeCloseTo(10 / 15)\n })\n})\n"}, "1093c98f6a40867ae0b86f0f58f8b8e94e23e0ad": {"t": 1785759330.7942865, "n": 7350, "h": "5d7c070027b474c7", "c": "import { describe, expect, it } from 'vitest'\n\nimport type { Channel, ChannelsResponse } from '@/services/api'\nimport { buildUnifiedChannelsData, type LlmChannelKind } from './unifiedChannels'\n\nconst channel = (\n name: string,\n accountUid: string,\n index: number,\n apiKeys: string[],\n overrides: Partial = {},\n): Channel => ({\n name,\n accountUid,\n channelUid: `ch-${index}`,\n providerId: 'mimo',\n autoManaged: true,\n index,\n serviceType: name.endsWith('-claude') ? 'claude' : 'openai',\n baseUrl: 'https://example.com',\n apiKeys,\n ...overrides,\n})\n\nconst response = (channels: Channel[]): ChannelsResponse => ({ channels, current: -1 })\n\ndescribe('buildUnifiedChannelsData account grouping', () => {\n it('优先按 accountUid 聚合多协议渠道,不依赖 Key 指纹', () => {\n const data: Record = {\n messages: response([channel('mimo-main-claude', 'acct-main', 0, ['sk-a'])]),\n chat: response([channel('mimo-main-chat', 'acct-main', 1, ['sk-a', 'sk-b'])]),\n responses: response([channel('mimo-main-codex', 'acct-main', 2, ['sk-b'])]),\n gemini: response([channel('mimo-main-gemini', 'acct-main', 3, ['sk-a'])]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].accountUid).toBe('acct-main')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses', 'gemini'])\n })\n\n it('相同 provider 和名称下不同 accountUid 不应合并', () => {\n const data: Record = {\n messages: response([\n channel('mimo-main-claude', 'acct-a', 0, ['sk-a']),\n channel('mimo-main-claude', 'acct-b', 1, ['sk-b']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n\n expect(buildUnifiedChannelsData(data).channels).toHaveLength(2)\n })\n\n it('按 accountUid 聚合自定义自动托管的全部成功协议', () => {\n const custom = (name: string, index: number, serviceType: Channel['serviceType']): Channel =>\n channel(name, 'acct-fastaitoken', index, ['sk-fastaitoken'], { providerId: '', autoManaged: true, serviceType })\n const data: Record = {\n messages: response([{\n ...custom('fastaitoken-com-test-claude', 0, 'claude'),\n supportedModels: ['gpt-5.6-sol', 'gpt-5.6-terra'],\n }]),\n chat: response([{\n ...custom('fastaitoken-com-test-chat', 0, 'openai'),\n supportedModels: ['gpt-5.6-sol'],\n }]),\n responses: response([{\n ...custom('fastaitoken-com-test-codex', 0, 'responses'),\n supportedModels: ['codex-auto-review', 'gpt-5.6-sol'],\n }]),\n gemini: response([]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].name).toBe('fastaitoken-com-test')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT', 'CODEX'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses'])\n expect(Object.fromEntries(\n result.channels[0].protocolRoutes?.map(route => [route.kind, route.supportedModels]) ?? [],\n )).toEqual({\n messages: ['gpt-5.6-sol', 'gpt-5.6-terra'],\n chat: ['gpt-5.6-sol'],\n responses: ['codex-auto-review', 'gpt-5.6-sol'],\n })\n })\n\n it('协议标签只展示上游实际提供的 serviceType', () => {\n const data: Record = {\n messages: response([channel('volcengine-claude', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'claude',\n })]),\n chat: response([channel('volcengine-chat', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n responses: response([channel('volcengine-codex', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n gemini: response([channel('volcengine-gemini', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n }\n\n const [volcengine] = buildUnifiedChannelsData(data).channels\n expect(volcengine.protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(volcengine.protocolRoutes).toHaveLength(4)\n })\n\n it('新增单协议渠道置顶后保持多协议账号的既有相对顺序', () => {\n const data: Record = {\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '', priority: 0 }),\n channel('volcengine-claude', 'acct-volcengine', 1, ['sk-volcengine'], { providerId: 'volcengine', priority: 1 }),\n channel('mimo-claude', 'acct-mimo', 2, ['sk-mimo'], { priority: 2 }),\n ]),\n chat: response([\n channel('volcengine-chat', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-chat', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('desktop-deepseek-chat', 'acct-deepseek', 34, ['sk-deepseek'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n responses: response([\n channel('volcengine-codex', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-codex', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('aixoras-xanqfm', 'acct-aixoras', 2, ['sk-aixoras'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n gemini: response([\n channel('volcengine-gemini', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-gemini', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n ]),\n }\n\n const channels = buildUnifiedChannelsData(data).channels\n const sorted = [...channels].sort((a, b) => (a.priority ?? a.index) - (b.priority ?? b.index))\n\n expect(sorted.slice(0, 5).map(item => item.name)).toEqual([\n 'localhost-37zq4d',\n 'volcengine',\n 'mimo',\n 'desktop-deepseek-chat',\n 'aixoras-xanqfm',\n ])\n expect(channels.find(item => item.name === 'mimo')?.priority).toBe(1)\n })\n\n it('在列表头部插入渠道时保持既有逻辑渠道的展示 key 稳定', () => {\n const original: Record = {\n messages: response([\n channel('volcengine-claude', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine' }),\n channel('mimo-claude', 'acct-mimo', 1, ['sk-mimo']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n const withNewChannel: Record = {\n ...original,\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '' }),\n ...original.messages.channels,\n ]),\n }\n\n const originalMimo = buildUnifiedChannelsData(original).channels.find(item => item.name === 'mimo')\n const nextMimo = buildUnifiedChannelsData(withNewChannel).channels.find(item => item.name === 'mimo')\n\n expect(nextMimo?.displayKey).toBe(originalMimo?.displayKey)\n })\n})\n"}, "b4fabcb69896d68027efca131ce606a40985e43c": {"t": 1785759332.426556, "n": 6833, "h": "30d1b7da72e6a346", "c": "import { describe, expect, it } from 'vitest'\n\nimport type { Channel, ChannelsResponse } from '@/services/api'\nimport { buildUnifiedChannelsData, type LlmChannelKind } from './unifiedChannels'\n\nconst channel = (\n name: string,\n accountUid: string,\n index: number,\n apiKeys: string[],\n overrides: Partial = {},\n): Channel => ({\n name,\n accountUid,\n channelUid: `ch-${index}`,\n providerId: 'mimo',\n autoManaged: true,\n index,\n serviceType: name.endsWith('-claude') ? 'claude' : 'openai',\n baseUrl: 'https://example.com',\n apiKeys,\n ...overrides,\n})\n\nconst response = (channels: Channel[]): ChannelsResponse => ({ channels, current: -1 })\n\ndescribe('buildUnifiedChannelsData account grouping', () => {\n it('优先按 accountUid 聚合多协议渠道,不依赖 Key 指纹', () => {\n const data: Record = {\n messages: response([channel('mimo-main-claude', 'acct-main', 0, ['sk-a'])]),\n chat: response([channel('mimo-main-chat', 'acct-main', 1, ['sk-a', 'sk-b'])]),\n responses: response([channel('mimo-main-codex', 'acct-main', 2, ['sk-b'])]),\n gemini: response([channel('mimo-main-gemini', 'acct-main', 3, ['sk-a'])]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].accountUid).toBe('acct-main')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses', 'gemini'])\n })\n\n it('相同 provider 和名称下不同 accountUid 不应合并', () => {\n const data: Record = {\n messages: response([\n channel('mimo-main-claude', 'acct-a', 0, ['sk-a']),\n channel('mimo-main-claude', 'acct-b', 1, ['sk-b']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n\n expect(buildUnifiedChannelsData(data).channels).toHaveLength(2)\n })\n\n it('按 accountUid 聚合自定义自动托管的全部成功协议', () => {\n const custom = (name: string, index: number, serviceType: Channel['serviceType']): Channel =>\n channel(name, 'acct-fastaitoken', index, ['sk-fastaitoken'], { providerId: '', autoManaged: true, serviceType })\n const data: Record = {\n messages: response([custom('fastaitoken-com-test-claude', 0, 'claude')]),\n chat: response([custom('fastaitoken-com-test-chat', 0, 'openai')]),\n responses: response([custom('fastaitoken-com-test-codex', 0, 'responses')]),\n gemini: response([]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].name).toBe('fastaitoken-com-test')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT', 'CODEX'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses'])\n })\n\n it('协议标签只展示上游实际提供的 serviceType', () => {\n const data: Record = {\n messages: response([channel('volcengine-claude', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'claude',\n })]),\n chat: response([channel('volcengine-chat', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n responses: response([channel('volcengine-codex', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n gemini: response([channel('volcengine-gemini', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n }\n\n const [volcengine] = buildUnifiedChannelsData(data).channels\n expect(volcengine.protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(volcengine.protocolRoutes).toHaveLength(4)\n })\n\n it('新增单协议渠道置顶后保持多协议账号的既有相对顺序', () => {\n const data: Record = {\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '', priority: 0 }),\n channel('volcengine-claude', 'acct-volcengine', 1, ['sk-volcengine'], { providerId: 'volcengine', priority: 1 }),\n channel('mimo-claude', 'acct-mimo', 2, ['sk-mimo'], { priority: 2 }),\n ]),\n chat: response([\n channel('volcengine-chat', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-chat', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('desktop-deepseek-chat', 'acct-deepseek', 34, ['sk-deepseek'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n responses: response([\n channel('volcengine-codex', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-codex', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('aixoras-xanqfm', 'acct-aixoras', 2, ['sk-aixoras'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n gemini: response([\n channel('volcengine-gemini', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-gemini', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n ]),\n }\n\n const channels = buildUnifiedChannelsData(data).channels\n const sorted = [...channels].sort((a, b) => (a.priority ?? a.index) - (b.priority ?? b.index))\n\n expect(sorted.slice(0, 5).map(item => item.name)).toEqual([\n 'localhost-37zq4d',\n 'volcengine',\n 'mimo',\n 'desktop-deepseek-chat',\n 'aixoras-xanqfm',\n ])\n expect(channels.find(item => item.name === 'mimo')?.priority).toBe(1)\n })\n\n it('在列表头部插入渠道时保持既有逻辑渠道的展示 key 稳定', () => {\n const original: Record = {\n messages: response([\n channel('volcengine-claude', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine' }),\n channel('mimo-claude', 'acct-mimo', 1, ['sk-mimo']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n const withNewChannel: Record = {\n ...original,\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '' }),\n ...original.messages.channels,\n ]),\n }\n\n const originalMimo = buildUnifiedChannelsData(original).channels.find(item => item.name === 'mimo')\n const nextMimo = buildUnifiedChannelsData(withNewChannel).channels.find(item => item.name === 'mimo')\n\n expect(nextMimo?.displayKey).toBe(originalMimo?.displayKey)\n })\n})\n"}, "478293e6559e67db3105e4dfb362d4dbf9d34b22": {"t": 1785759335.0858684, "n": 5849, "h": "4da30e54eaf8252b", "c": "import { describe, expect, it } from 'vitest'\n\nimport type { Channel, ChannelsResponse } from '@/services/api'\nimport { buildUnifiedChannelsData, type LlmChannelKind } from './unifiedChannels'\n\nconst channel = (\n name: string,\n accountUid: string,\n index: number,\n apiKeys: string[],\n overrides: Partial = {},\n): Channel => ({\n name,\n accountUid,\n channelUid: `ch-${index}`,\n providerId: 'mimo',\n autoManaged: true,\n index,\n serviceType: name.endsWith('-claude') ? 'claude' : 'openai',\n baseUrl: 'https://example.com',\n apiKeys,\n ...overrides,\n})\n\nconst response = (channels: Channel[]): ChannelsResponse => ({ channels, current: -1 })\n\ndescribe('buildUnifiedChannelsData account grouping', () => {\n it('优先按 accountUid 聚合多协议渠道,不依赖 Key 指纹', () => {\n const data: Record = {\n messages: response([channel('mimo-main-claude', 'acct-main', 0, ['sk-a'])]),\n chat: response([channel('mimo-main-chat', 'acct-main', 1, ['sk-a', 'sk-b'])]),\n responses: response([channel('mimo-main-codex', 'acct-main', 2, ['sk-b'])]),\n gemini: response([channel('mimo-main-gemini', 'acct-main', 3, ['sk-a'])]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].accountUid).toBe('acct-main')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses', 'gemini'])\n })\n\n it('相同 provider 和名称下不同 accountUid 不应合并', () => {\n const data: Record = {\n messages: response([\n channel('mimo-main-claude', 'acct-a', 0, ['sk-a']),\n channel('mimo-main-claude', 'acct-b', 1, ['sk-b']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n\n expect(buildUnifiedChannelsData(data).channels).toHaveLength(2)\n })\n\n it('协议标签只展示上游实际提供的 serviceType', () => {\n const data: Record = {\n messages: response([channel('volcengine-claude', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'claude',\n })]),\n chat: response([channel('volcengine-chat', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n responses: response([channel('volcengine-codex', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n gemini: response([channel('volcengine-gemini', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n }\n\n const [volcengine] = buildUnifiedChannelsData(data).channels\n expect(volcengine.protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(volcengine.protocolRoutes).toHaveLength(4)\n })\n\n it('新增单协议渠道置顶后保持多协议账号的既有相对顺序', () => {\n const data: Record = {\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '', priority: 0 }),\n channel('volcengine-claude', 'acct-volcengine', 1, ['sk-volcengine'], { providerId: 'volcengine', priority: 1 }),\n channel('mimo-claude', 'acct-mimo', 2, ['sk-mimo'], { priority: 2 }),\n ]),\n chat: response([\n channel('volcengine-chat', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-chat', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('desktop-deepseek-chat', 'acct-deepseek', 34, ['sk-deepseek'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n responses: response([\n channel('volcengine-codex', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-codex', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('aixoras-xanqfm', 'acct-aixoras', 2, ['sk-aixoras'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n gemini: response([\n channel('volcengine-gemini', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-gemini', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n ]),\n }\n\n const channels = buildUnifiedChannelsData(data).channels\n const sorted = [...channels].sort((a, b) => (a.priority ?? a.index) - (b.priority ?? b.index))\n\n expect(sorted.slice(0, 5).map(item => item.name)).toEqual([\n 'localhost-37zq4d',\n 'volcengine',\n 'mimo',\n 'desktop-deepseek-chat',\n 'aixoras-xanqfm',\n ])\n expect(channels.find(item => item.name === 'mimo')?.priority).toBe(1)\n })\n\n it('在列表头部插入渠道时保持既有逻辑渠道的展示 key 稳定', () => {\n const original: Record = {\n messages: response([\n channel('volcengine-claude', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine' }),\n channel('mimo-claude', 'acct-mimo', 1, ['sk-mimo']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n const withNewChannel: Record = {\n ...original,\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '' }),\n ...original.messages.channels,\n ]),\n }\n\n const originalMimo = buildUnifiedChannelsData(original).channels.find(item => item.name === 'mimo')\n const nextMimo = buildUnifiedChannelsData(withNewChannel).channels.find(item => item.name === 'mimo')\n\n expect(nextMimo?.displayKey).toBe(originalMimo?.displayKey)\n })\n})\n"}, "497c5f377553af91899670052f6474f9f5ff701f": {"t": 1785759336.492618, "n": 4784, "h": "508edff1de18b60b", "c": "import { describe, expect, it } from 'vitest'\n\nimport type { Channel, ChannelsResponse } from '@/services/api'\nimport { buildUnifiedChannelsData, type LlmChannelKind } from './unifiedChannels'\n\nconst channel = (\n name: string,\n accountUid: string,\n index: number,\n apiKeys: string[],\n overrides: Partial = {},\n): Channel => ({\n name,\n accountUid,\n channelUid: `ch-${index}`,\n providerId: 'mimo',\n autoManaged: true,\n index,\n serviceType: name.endsWith('-claude') ? 'claude' : 'openai',\n baseUrl: 'https://example.com',\n apiKeys,\n ...overrides,\n})\n\nconst response = (channels: Channel[]): ChannelsResponse => ({ channels, current: -1 })\n\ndescribe('buildUnifiedChannelsData account grouping', () => {\n it('优先按 accountUid 聚合多协议渠道,不依赖 Key 指纹', () => {\n const data: Record = {\n messages: response([channel('mimo-main-claude', 'acct-main', 0, ['sk-a'])]),\n chat: response([channel('mimo-main-chat', 'acct-main', 1, ['sk-a', 'sk-b'])]),\n responses: response([channel('mimo-main-codex', 'acct-main', 2, ['sk-b'])]),\n gemini: response([channel('mimo-main-gemini', 'acct-main', 3, ['sk-a'])]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].accountUid).toBe('acct-main')\n expect(result.channels[0].protocolCapsules?.map(item => item.kind)).toEqual([\n 'messages',\n 'chat',\n 'responses',\n 'gemini',\n ])\n })\n\n it('相同 provider 和名称下不同 accountUid 不应合并', () => {\n const data: Record = {\n messages: response([\n channel('mimo-main-claude', 'acct-a', 0, ['sk-a']),\n channel('mimo-main-claude', 'acct-b', 1, ['sk-b']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n\n expect(buildUnifiedChannelsData(data).channels).toHaveLength(2)\n })\n\n it('新增单协议渠道置顶后保持多协议账号的既有相对顺序', () => {\n const data: Record = {\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '', priority: 0 }),\n channel('volcengine-claude', 'acct-volcengine', 1, ['sk-volcengine'], { providerId: 'volcengine', priority: 1 }),\n channel('mimo-claude', 'acct-mimo', 2, ['sk-mimo'], { priority: 2 }),\n ]),\n chat: response([\n channel('volcengine-chat', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-chat', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('desktop-deepseek-chat', 'acct-deepseek', 34, ['sk-deepseek'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n responses: response([\n channel('volcengine-codex', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-codex', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('aixoras-xanqfm', 'acct-aixoras', 2, ['sk-aixoras'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n gemini: response([\n channel('volcengine-gemini', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-gemini', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n ]),\n }\n\n const channels = buildUnifiedChannelsData(data).channels\n const sorted = [...channels].sort((a, b) => (a.priority ?? a.index) - (b.priority ?? b.index))\n\n expect(sorted.slice(0, 5).map(item => item.name)).toEqual([\n 'localhost-37zq4d',\n 'volcengine',\n 'mimo',\n 'desktop-deepseek-chat',\n 'aixoras-xanqfm',\n ])\n expect(channels.find(item => item.name === 'mimo')?.priority).toBe(1)\n })\n\n it('在列表头部插入渠道时保持既有逻辑渠道的展示 key 稳定', () => {\n const original: Record = {\n messages: response([\n channel('volcengine-claude', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine' }),\n channel('mimo-claude', 'acct-mimo', 1, ['sk-mimo']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n const withNewChannel: Record = {\n ...original,\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '' }),\n ...original.messages.channels,\n ]),\n }\n\n const originalMimo = buildUnifiedChannelsData(original).channels.find(item => item.name === 'mimo')\n const nextMimo = buildUnifiedChannelsData(withNewChannel).channels.find(item => item.name === 'mimo')\n\n expect(nextMimo?.displayKey).toBe(originalMimo?.displayKey)\n })\n})\n"}, "041957fd180475da88e2e80261605cae493f2220": {"t": 1785759338.919276, "n": 1863, "h": "021b7110d9a467cb", "c": "import { describe, expect, it } from 'vitest'\n\nimport type { Channel, ChannelsResponse } from '@/services/api'\nimport { buildUnifiedChannelsData, type LlmChannelKind } from './unifiedChannels'\n\nconst channel = (name: string, accountUid: string, index: number, apiKeys: string[]): Channel => ({\n name,\n accountUid,\n channelUid: `ch-${index}`,\n providerId: 'mimo',\n autoManaged: true,\n index,\n serviceType: name.endsWith('-claude') ? 'claude' : 'openai',\n baseUrl: 'https://example.com',\n apiKeys,\n})\n\nconst response = (channels: Channel[]): ChannelsResponse => ({ channels, current: -1 })\n\ndescribe('buildUnifiedChannelsData account grouping', () => {\n it('优先按 accountUid 聚合多协议渠道,不依赖 Key 指纹', () => {\n const data: Record = {\n messages: response([channel('mimo-main-claude', 'acct-main', 0, ['sk-a'])]),\n chat: response([channel('mimo-main-chat', 'acct-main', 1, ['sk-a', 'sk-b'])]),\n responses: response([channel('mimo-main-codex', 'acct-main', 2, ['sk-b'])]),\n gemini: response([channel('mimo-main-gemini', 'acct-main', 3, ['sk-a'])]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].accountUid).toBe('acct-main')\n expect(result.channels[0].protocolCapsules?.map(item => item.kind)).toEqual([\n 'messages',\n 'chat',\n 'responses',\n 'gemini',\n ])\n })\n\n it('相同 provider 和名称下不同 accountUid 不应合并', () => {\n const data: Record = {\n messages: response([\n channel('mimo-main-claude', 'acct-a', 0, ['sk-a']),\n channel('mimo-main-claude', 'acct-b', 1, ['sk-b']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n\n expect(buildUnifiedChannelsData(data).channels).toHaveLength(2)\n })\n})\n"}, "71b842e3e8300d0b9329446af03a39c498580b55": {"t": 1786068175.2158198, "n": 18872, "h": "6c3f77c4b7979cc6", "c": "import { describe, expect, it } from 'vitest'\n\nimport type { Channel, ChannelMetrics, ChannelRecentActivity, ChannelsResponse } from '@/services/api'\nimport { buildUnifiedChannelsData, buildUnifiedRecentActivity, buildUnifiedReorderPayloads, normalizeChannelStatus, PartialRouteOperationError, resolveChannelStatusMutationRoutes, resolveChannelRecoveryRoutes, routeHasOnlyDisabledKeys, summarizeSettledRouteOperations, withRouteKindMetrics, type LlmChannelKind } from './unifiedChannels'\n\nconst channel = (\n name: string,\n accountUid: string,\n index: number,\n apiKeys: string[],\n overrides: Partial = {},\n): Channel => ({\n name,\n accountUid,\n channelUid: `ch-${index}`,\n providerId: 'mimo',\n autoManaged: true,\n index,\n serviceType: name.endsWith('-claude') ? 'claude' : 'openai',\n baseUrl: 'https://example.com',\n apiKeys,\n ...overrides,\n})\n\nconst response = (channels: Channel[]): ChannelsResponse => ({ channels, current: -1 })\n\ndescribe('logical channel display status', () => {\n const groupedStatus = (...statuses: Array) => {\n const kinds: LlmChannelKind[] = ['messages', 'chat', 'responses', 'gemini']\n const data = Object.fromEntries(kinds.map(kind => [kind, response([])])) as Record\n statuses.forEach((status, index) => {\n const kind = kinds[index]\n data[kind] = response([channel(`kimi-${kind}`, 'acct-status', index, ['sk-status'], { status })])\n })\n return buildUnifiedChannelsData(data).channels[0]\n }\n\n it.each([\n [undefined, 'active'],\n ['', 'active'],\n ['healthy', 'active'],\n ['active', 'active'],\n ['suspended', 'suspended'],\n ['disabled', 'disabled'],\n ['error', 'error'],\n ['unknown', 'unknown'],\n ] as const)('统一状态 %s 归一化为 %s', (status, expected) => {\n expect(normalizeChannelStatus(status)).toBe(expected)\n expect(groupedStatus(status, status).status).toBe(expected)\n })\n\n it.each([\n ['active', 'suspended'],\n ['active', 'disabled'],\n ['suspended', 'disabled'],\n ['healthy', 'suspended'],\n [undefined, 'disabled'],\n ] as const)('混合状态 %s + %s 聚合为 partial', (left, right) => {\n expect(groupedStatus(left, right).status).toBe('partial')\n })\n\n it('Kimi 类 messages 暂停而 chat/responses 活跃时显示部分可用并保留物理状态', () => {\n const logicalChannel = groupedStatus('suspended', 'active', 'active')\n expect(logicalChannel.status).toBe('partial')\n expect(logicalChannel.protocolRoutes?.map(route => route.status)).toEqual([\n 'suspended',\n 'active',\n 'active',\n ])\n })\n})\n\ndescribe('channel status mutations', () => {\n const logicalChannel = (): Channel => ({\n ...channel('kimi-claude', 'acct-kimi', 0, ['sk-kimi'], { status: 'partial' }),\n protocolRoutes: [\n { kind: 'messages', index: 0, name: 'kimi-claude', serviceType: 'claude', status: 'suspended' },\n { kind: 'chat', index: 1, name: 'kimi-chat', serviceType: 'openai', status: 'active' },\n { kind: 'responses', index: 2, name: 'kimi-codex', serviceType: 'responses', status: 'active' },\n ],\n })\n\n it('状态更新只生成 transport status,不会把 partial 发送给 API', () => {\n expect(resolveChannelStatusMutationRoutes(logicalChannel(), 'suspended')).toEqual([\n { kind: 'messages', index: 0, status: 'suspended' },\n { kind: 'chat', index: 1, status: 'suspended' },\n { kind: 'responses', index: 2, status: 'suspended' },\n ])\n expect(() => resolveChannelStatusMutationRoutes(logicalChannel(), 'partial')).toThrow(\n 'Display-only channel status cannot be persisted: partial',\n )\n })\n\n it('暂停 active+disabled 的部分可用渠道时保留 disabled 路由', () => {\n const mixed = logicalChannel()\n mixed.protocolRoutes![0].status = 'active'\n mixed.protocolRoutes![1].status = 'disabled'\n expect(resolveChannelStatusMutationRoutes(mixed, 'suspended')).toEqual([\n { kind: 'messages', index: 0, status: 'suspended' },\n { kind: 'responses', index: 2, status: 'suspended' },\n ])\n })\n\n it('显式禁用逻辑渠道时 fan-out 到全部物理路由', () => {\n const mixed = logicalChannel()\n mixed.protocolRoutes![1].status = 'disabled'\n expect(resolveChannelStatusMutationRoutes(mixed, 'disabled')).toEqual([\n { kind: 'messages', index: 0, status: 'disabled' },\n { kind: 'chat', index: 1, status: 'disabled' },\n { kind: 'responses', index: 2, status: 'disabled' },\n ])\n })\n})\n\ndescribe('settled route operations', () => {\n it('保留部分成功与失败计数,供组件刷新后提示部分失败', () => {\n const summary = summarizeSettledRouteOperations([\n { status: 'fulfilled', value: 2 },\n { status: 'rejected', reason: new Error('chat failed') },\n { status: 'fulfilled', value: 1 },\n ])\n expect(summary).toMatchObject({ fulfilled: [2, 1], successCount: 2, failureCount: 1, totalCount: 3 })\n const error = new PartialRouteOperationError(summary)\n expect(error).toMatchObject({ successCount: 2, failureCount: 1, totalCount: 3 })\n expect(error.message).toContain('chat failed')\n })\n})\n\ndescribe('withRouteKindMetrics', () => {\n const metric = (routeKind?: Channel['routeKind']): ChannelMetrics => ({\n channelIndex: 1,\n routeKind,\n requestCount: 0,\n successCount: 0,\n failureCount: 0,\n successRate: 0,\n errorRate: 0,\n consecutiveFailures: 0,\n latency: 0,\n })\n\n it('routeKind 已存在时复用指标数组和对象引用', () => {\n const metrics = [metric('messages')]\n expect(withRouteKindMetrics('messages', metrics)).toBe(metrics)\n })\n\n it('仅为旧格式指标补充 routeKind', () => {\n const metrics = [metric()]\n const result = withRouteKindMetrics('chat', metrics)\n expect(result).not.toBe(metrics)\n expect(result[0]).toEqual({ ...metrics[0], routeKind: 'chat' })\n })\n})\n\ndescribe('buildUnifiedChannelsData account grouping', () => {\n it('优先按 accountUid 聚合多协议渠道,不依赖 Key 指纹', () => {\n const data: Record = {\n messages: response([channel('mimo-main-claude', 'acct-main', 0, ['sk-a'])]),\n chat: response([channel('mimo-main-chat', 'acct-main', 1, ['sk-a', 'sk-b'])]),\n responses: response([channel('mimo-main-codex', 'acct-main', 2, ['sk-b'])]),\n gemini: response([channel('mimo-main-gemini', 'acct-main', 3, ['sk-a'])]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].accountUid).toBe('acct-main')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses', 'gemini'])\n expect(result.channels[0].protocolRoutes?.map(item => item.status)).toEqual([undefined, undefined, undefined, undefined])\n expect(result.channels[0].protocolRoutes?.map(item => item.apiKeys)).toEqual([\n ['sk-a'],\n ['sk-a', 'sk-b'],\n ['sk-b'],\n ['sk-a'],\n ])\n })\n\n it('保留物理路由各自的 Key 配置与拉黑状态,避免共享 Key 误判', () => {\n const disabled = {\n key: 'sk-shared',\n reason: 'authentication_error',\n message: 'invalid only on messages',\n disabledAt: '2026-07-28T00:00:00Z',\n }\n const data: Record = {\n messages: response([channel('kimi-claude', 'acct-route-keys', 0, [], {\n status: 'active',\n disabledApiKeys: [disabled],\n apiKeyConfigs: [{ key: 'sk-shared', enabled: true }],\n })]),\n chat: response([channel('kimi-chat', 'acct-route-keys', 1, ['sk-shared'], {\n status: 'active',\n apiKeyConfigs: [{ key: 'sk-shared', enabled: true }],\n })]),\n responses: response([]),\n gemini: response([]),\n }\n\n const routes = buildUnifiedChannelsData(data).channels[0].protocolRoutes!\n expect(routes[0]).toMatchObject({\n kind: 'messages',\n apiKeys: [],\n apiKeyConfigs: [{ key: 'sk-shared', enabled: true }],\n disabledApiKeys: [disabled],\n })\n expect(routes[1]).toMatchObject({\n kind: 'chat',\n apiKeys: ['sk-shared'],\n apiKeyConfigs: [{ key: 'sk-shared', enabled: true }],\n disabledApiKeys: undefined,\n })\n expect(routeHasOnlyDisabledKeys(routes[0])).toBe(true)\n expect(routeHasOnlyDisabledKeys(routes[1])).toBe(false)\n })\n\n it('聚合账号合并各协议凭证并按全部路由计算状态', () => {\n const data: Record = {\n messages: response([channel('kimi-claude', 'acct-kimi', 0, [], {\n status: 'suspended',\n })]),\n chat: response([channel('kimi-chat', 'acct-kimi', 1, ['sk-kimi', 'sk-disabled'], {\n status: 'active',\n apiKeyConfigs: [{ key: 'sk-kimi', credentialUid: 'cred-kimi' }],\n disabledApiKeys: [{\n key: 'sk-disabled',\n reason: 'authentication_error',\n message: 'invalid key',\n disabledAt: '2026-07-28T00:00:00Z',\n }],\n })]),\n responses: response([]),\n gemini: response([]),\n }\n\n const [logicalChannel] = buildUnifiedChannelsData(data).channels\n expect(logicalChannel.apiKeys).toEqual(['sk-kimi', 'sk-disabled'])\n expect(logicalChannel.apiKeyConfigs).toEqual([\n { key: 'sk-kimi', credentialUid: 'cred-kimi' },\n ])\n expect(logicalChannel.disabledApiKeys).toEqual([{\n key: 'sk-disabled',\n reason: 'authentication_error',\n message: 'invalid key',\n disabledAt: '2026-07-28T00:00:00Z',\n }])\n expect(logicalChannel.status).toBe('partial')\n })\n\n it('保留各协议路由状态供聚合渠道恢复使用', () => {\n const data: Record = {\n messages: response([channel('mimo-main-claude', 'acct-main', 0, ['sk-a'], { status: 'suspended' })]),\n chat: response([channel('mimo-main-chat', 'acct-main', 1, ['sk-a'], { status: 'active' })]),\n responses: response([channel('mimo-main-codex', 'acct-main', 2, ['sk-a'], { status: 'disabled' })]),\n gemini: response([]),\n }\n\n const logicalChannel = buildUnifiedChannelsData(data).channels[0]\n expect(logicalChannel.protocolRoutes?.map(route => route.status)).toEqual([\n 'suspended',\n 'active',\n 'disabled',\n ])\n expect(resolveChannelRecoveryRoutes(logicalChannel)).toEqual([\n { kind: 'messages', index: 0, status: 'suspended' },\n { kind: 'chat', index: 1, status: 'active' },\n { kind: 'responses', index: 2, status: 'disabled' },\n ])\n })\n\n it('相同 provider 和名称下不同 accountUid 不应合并', () => {\n const data: Record = {\n messages: response([\n channel('mimo-main-claude', 'acct-a', 0, ['sk-a']),\n channel('mimo-main-claude', 'acct-b', 1, ['sk-b']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n\n expect(buildUnifiedChannelsData(data).channels).toHaveLength(2)\n })\n\n it('按 accountUid 聚合自定义自动托管的全部成功协议', () => {\n const custom = (name: string, index: number, serviceType: Channel['serviceType']): Channel =>\n channel(name, 'acct-fastaitoken', index, ['sk-fastaitoken'], { providerId: '', autoManaged: true, serviceType })\n const data: Record = {\n messages: response([{\n ...custom('fastaitoken-com-test-claude', 0, 'claude'),\n supportedModels: ['gpt-5.6-sol', 'gpt-5.6-terra'],\n }]),\n chat: response([{\n ...custom('fastaitoken-com-test-chat', 0, 'openai'),\n supportedModels: ['gpt-5.6-sol'],\n }]),\n responses: response([{\n ...custom('fastaitoken-com-test-codex', 0, 'responses'),\n supportedModels: ['codex-auto-review', 'gpt-5.6-sol'],\n }]),\n gemini: response([]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].name).toBe('fastaitoken-com-test')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT', 'CODEX'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses'])\n expect(Object.fromEntries(\n result.channels[0].protocolRoutes?.map(route => [route.kind, route.supportedModels]) ?? [],\n )).toEqual({\n messages: ['gpt-5.6-sol', 'gpt-5.6-terra'],\n chat: ['gpt-5.6-sol'],\n responses: ['codex-auto-review', 'gpt-5.6-sol'],\n })\n })\n\n it('协议标签只展示上游实际提供的 serviceType', () => {\n const data: Record = {\n messages: response([channel('volcengine-claude', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'claude',\n })]),\n chat: response([channel('volcengine-chat', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n responses: response([channel('volcengine-codex', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n gemini: response([channel('volcengine-gemini', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n }\n\n const [volcengine] = buildUnifiedChannelsData(data).channels\n expect(volcengine.protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(volcengine.protocolRoutes).toHaveLength(4)\n expect(volcengine.protocolRoutes?.every(route => route.supportedModels === undefined)).toBe(true)\n })\n\n it('新增单协议渠道置顶后保持多协议账号的既有相对顺序', () => {\n const data: Record = {\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '', priority: 0 }),\n channel('volcengine-claude', 'acct-volcengine', 1, ['sk-volcengine'], { providerId: 'volcengine', priority: 1 }),\n channel('mimo-claude', 'acct-mimo', 2, ['sk-mimo'], { priority: 2 }),\n ]),\n chat: response([\n channel('volcengine-chat', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-chat', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('desktop-deepseek-chat', 'acct-deepseek', 34, ['sk-deepseek'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n responses: response([\n channel('volcengine-codex', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-codex', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('aixoras-xanqfm', 'acct-aixoras', 2, ['sk-aixoras'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n gemini: response([\n channel('volcengine-gemini', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-gemini', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n ]),\n }\n\n const channels = buildUnifiedChannelsData(data).channels\n const sorted = [...channels].sort((a, b) => (a.priority ?? a.index) - (b.priority ?? b.index))\n\n expect(sorted.slice(0, 5).map(item => item.name)).toEqual([\n 'localhost-37zq4d',\n 'volcengine',\n 'mimo',\n 'desktop-deepseek-chat',\n 'aixoras-xanqfm',\n ])\n expect(channels.find(item => item.name === 'mimo')?.priority).toBe(1)\n })\n\n it('在列表头部插入渠道时保持既有逻辑渠道的展示 key 稳定', () => {\n const original: Record = {\n messages: response([\n channel('volcengine-claude', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine' }),\n channel('mimo-claude', 'acct-mimo', 1, ['sk-mimo']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n const withNewChannel: Record = {\n ...original,\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '' }),\n ...original.messages.channels,\n ]),\n }\n\n const originalMimo = buildUnifiedChannelsData(original).channels.find(item => item.name === 'mimo')\n const nextMimo = buildUnifiedChannelsData(withNewChannel).channels.find(item => item.name === 'mimo')\n\n expect(nextMimo?.displayKey).toBe(originalMimo?.displayKey)\n })\n\n it('聚合逻辑渠道全部协议的最近请求活动', () => {\n const data: Record = {\n messages: response([channel('mimo-claude', 'acct-mimo', 0, ['sk-mimo'])]),\n chat: response([channel('mimo-chat', 'acct-mimo', 1, ['sk-mimo'])]),\n responses: response([]),\n gemini: response([]),\n }\n const [logicalChannel] = buildUnifiedChannelsData(data).channels\n const activity = (\n channelIndex: number,\n requestCount: number,\n successCount: number,\n failureCount: number,\n ): ChannelRecentActivity => ({\n channelIndex,\n segments: {\n 4: { requestCount, successCount, failureCount, inputTokens: requestCount * 10, outputTokens: requestCount * 2 },\n },\n totalSegs: 150,\n rpm: requestCount / 15,\n tpm: requestCount * 2 / 15,\n })\n\n const [merged] = buildUnifiedRecentActivity([logicalChannel], {\n messages: [activity(0, 2, 2, 0)],\n chat: [activity(1, 3, 2, 1)],\n responses: [],\n gemini: [],\n })\n\n expect(merged.channelIndex).toBe(0)\n expect(merged.routeKind).toBe('messages')\n expect(merged.segments[4]).toEqual({\n requestCount: 5,\n successCount: 4,\n failureCount: 1,\n inputTokens: 50,\n outputTokens: 10,\n })\n expect(merged.rpm).toBeCloseTo(5 / 15)\n expect(merged.tpm).toBeCloseTo(10 / 15)\n })\n})\n\ndescribe('buildUnifiedReorderPayloads', () => {\n it('priority 使用统一列表全局位次而非各协议组内名次', () => {\n const data: Record = {\n messages: response([\n channel('a-claude', 'acct-a', 0, ['sk-a']),\n channel('b-claude', 'acct-b', 1, ['sk-b']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([channel('g-gemini', 'acct-g', 0, ['sk-g'], { serviceType: 'gemini' })]),\n }\n const unified = buildUnifiedChannelsData(data).channels\n const a = unified.find(c => c.protocolRoutes?.some(r => r.kind === 'messages' && r.index === 0))!\n const b = unified.find(c => c.protocolRoutes?.some(r => r.kind === 'messages' && r.index === 1))!\n const g = unified.find(c => c.protocolRoutes?.some(r => r.kind === 'gemini'))!\n\n // 模拟拖拽后的统一列表顺序:a, g, b\n const payloads = buildUnifiedReorderPayloads([a, g, b])\n\n // messages:a 位次 1,b 位次 3(中间隔着 gemini 渠道)\n expect(payloads.get('messages')).toEqual({ order: [0, 1], priorities: [1, 3] })\n // gemini:g 位次 2。若按组内名次编号会得到 1,刷新后 min(priority) 会把 g 提到最前\n expect(payloads.get('gemini')).toEqual({ order: [0], priorities: [2] })\n expect(payloads.has('chat')).toBe(false)\n })\n\n it('无 protocolRoutes 的渠道不产生载荷', () => {\n const payloads = buildUnifiedReorderPayloads([{ name: 'plain', index: 0 } as Channel])\n expect(payloads.size).toBe(0)\n })\n})\n"}, "8cb1f08da90e4858885f246ddadd488390fdba08": {"t": 1786068621.1488705, "n": 19736, "h": "0a2cbf91b48e4522", "c": "import { describe, expect, it } from 'vitest'\n\nimport type { Channel, ChannelMetrics, ChannelRecentActivity, ChannelsResponse } from '@/services/api'\nimport { buildUnifiedChannelsData, buildUnifiedRecentActivity, buildUnifiedReorderPayloads, normalizeChannelStatus, PartialRouteOperationError, resolveChannelStatusMutationRoutes, resolveChannelRecoveryRoutes, routeHasOnlyDisabledKeys, summarizeSettledRouteOperations, withRouteKindMetrics, type LlmChannelKind } from './unifiedChannels'\n\nconst channel = (\n name: string,\n accountUid: string,\n index: number,\n apiKeys: string[],\n overrides: Partial = {},\n): Channel => ({\n name,\n accountUid,\n channelUid: `ch-${index}`,\n providerId: 'mimo',\n autoManaged: true,\n index,\n serviceType: name.endsWith('-claude') ? 'claude' : 'openai',\n baseUrl: 'https://example.com',\n apiKeys,\n ...overrides,\n})\n\nconst response = (channels: Channel[]): ChannelsResponse => ({ channels, current: -1 })\n\ndescribe('logical channel display status', () => {\n const groupedStatus = (...statuses: Array) => {\n const kinds: LlmChannelKind[] = ['messages', 'chat', 'responses', 'gemini']\n const data = Object.fromEntries(kinds.map(kind => [kind, response([])])) as Record\n statuses.forEach((status, index) => {\n const kind = kinds[index]\n data[kind] = response([channel(`kimi-${kind}`, 'acct-status', index, ['sk-status'], { status })])\n })\n return buildUnifiedChannelsData(data).channels[0]\n }\n\n it.each([\n [undefined, 'active'],\n ['', 'active'],\n ['healthy', 'active'],\n ['active', 'active'],\n ['suspended', 'suspended'],\n ['disabled', 'disabled'],\n ['error', 'error'],\n ['unknown', 'unknown'],\n ] as const)('统一状态 %s 归一化为 %s', (status, expected) => {\n expect(normalizeChannelStatus(status)).toBe(expected)\n expect(groupedStatus(status, status).status).toBe(expected)\n })\n\n it.each([\n ['active', 'suspended'],\n ['active', 'disabled'],\n ['suspended', 'disabled'],\n ['healthy', 'suspended'],\n [undefined, 'disabled'],\n ] as const)('混合状态 %s + %s 聚合为 partial', (left, right) => {\n expect(groupedStatus(left, right).status).toBe('partial')\n })\n\n it('Kimi 类 messages 暂停而 chat/responses 活跃时显示部分可用并保留物理状态', () => {\n const logicalChannel = groupedStatus('suspended', 'active', 'active')\n expect(logicalChannel.status).toBe('partial')\n expect(logicalChannel.protocolRoutes?.map(route => route.status)).toEqual([\n 'suspended',\n 'active',\n 'active',\n ])\n })\n})\n\ndescribe('channel status mutations', () => {\n const logicalChannel = (): Channel => ({\n ...channel('kimi-claude', 'acct-kimi', 0, ['sk-kimi'], { status: 'partial' }),\n protocolRoutes: [\n { kind: 'messages', index: 0, name: 'kimi-claude', serviceType: 'claude', status: 'suspended' },\n { kind: 'chat', index: 1, name: 'kimi-chat', serviceType: 'openai', status: 'active' },\n { kind: 'responses', index: 2, name: 'kimi-codex', serviceType: 'responses', status: 'active' },\n ],\n })\n\n it('状态更新只生成 transport status,不会把 partial 发送给 API', () => {\n expect(resolveChannelStatusMutationRoutes(logicalChannel(), 'suspended')).toEqual([\n { kind: 'messages', index: 0, status: 'suspended' },\n { kind: 'chat', index: 1, status: 'suspended' },\n { kind: 'responses', index: 2, status: 'suspended' },\n ])\n expect(() => resolveChannelStatusMutationRoutes(logicalChannel(), 'partial')).toThrow(\n 'Display-only channel status cannot be persisted: partial',\n )\n })\n\n it('暂停 active+disabled 的部分可用渠道时保留 disabled 路由', () => {\n const mixed = logicalChannel()\n mixed.protocolRoutes![0].status = 'active'\n mixed.protocolRoutes![1].status = 'disabled'\n expect(resolveChannelStatusMutationRoutes(mixed, 'suspended')).toEqual([\n { kind: 'messages', index: 0, status: 'suspended' },\n { kind: 'responses', index: 2, status: 'suspended' },\n ])\n })\n\n it('显式禁用逻辑渠道时 fan-out 到全部物理路由', () => {\n const mixed = logicalChannel()\n mixed.protocolRoutes![1].status = 'disabled'\n expect(resolveChannelStatusMutationRoutes(mixed, 'disabled')).toEqual([\n { kind: 'messages', index: 0, status: 'disabled' },\n { kind: 'chat', index: 1, status: 'disabled' },\n { kind: 'responses', index: 2, status: 'disabled' },\n ])\n })\n})\n\ndescribe('settled route operations', () => {\n it('保留部分成功与失败计数,供组件刷新后提示部分失败', () => {\n const summary = summarizeSettledRouteOperations([\n { status: 'fulfilled', value: 2 },\n { status: 'rejected', reason: new Error('chat failed') },\n { status: 'fulfilled', value: 1 },\n ])\n expect(summary).toMatchObject({ fulfilled: [2, 1], successCount: 2, failureCount: 1, totalCount: 3 })\n const error = new PartialRouteOperationError(summary)\n expect(error).toMatchObject({ successCount: 2, failureCount: 1, totalCount: 3 })\n expect(error.message).toContain('chat failed')\n })\n})\n\ndescribe('withRouteKindMetrics', () => {\n const metric = (routeKind?: Channel['routeKind']): ChannelMetrics => ({\n channelIndex: 1,\n routeKind,\n requestCount: 0,\n successCount: 0,\n failureCount: 0,\n successRate: 0,\n errorRate: 0,\n consecutiveFailures: 0,\n latency: 0,\n })\n\n it('routeKind 已存在时复用指标数组和对象引用', () => {\n const metrics = [metric('messages')]\n expect(withRouteKindMetrics('messages', metrics)).toBe(metrics)\n })\n\n it('仅为旧格式指标补充 routeKind', () => {\n const metrics = [metric()]\n const result = withRouteKindMetrics('chat', metrics)\n expect(result).not.toBe(metrics)\n expect(result[0]).toEqual({ ...metrics[0], routeKind: 'chat' })\n })\n})\n\ndescribe('buildUnifiedChannelsData account grouping', () => {\n it('优先按 accountUid 聚合多协议渠道,不依赖 Key 指纹', () => {\n const data: Record = {\n messages: response([channel('mimo-main-claude', 'acct-main', 0, ['sk-a'])]),\n chat: response([channel('mimo-main-chat', 'acct-main', 1, ['sk-a', 'sk-b'])]),\n responses: response([channel('mimo-main-codex', 'acct-main', 2, ['sk-b'])]),\n gemini: response([channel('mimo-main-gemini', 'acct-main', 3, ['sk-a'])]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].accountUid).toBe('acct-main')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses', 'gemini'])\n expect(result.channels[0].protocolRoutes?.map(item => item.status)).toEqual([undefined, undefined, undefined, undefined])\n expect(result.channels[0].protocolRoutes?.map(item => item.apiKeys)).toEqual([\n ['sk-a'],\n ['sk-a', 'sk-b'],\n ['sk-b'],\n ['sk-a'],\n ])\n })\n\n it('保留物理路由各自的 Key 配置与拉黑状态,避免共享 Key 误判', () => {\n const disabled = {\n key: 'sk-shared',\n reason: 'authentication_error',\n message: 'invalid only on messages',\n disabledAt: '2026-07-28T00:00:00Z',\n }\n const data: Record = {\n messages: response([channel('kimi-claude', 'acct-route-keys', 0, [], {\n status: 'active',\n disabledApiKeys: [disabled],\n apiKeyConfigs: [{ key: 'sk-shared', enabled: true }],\n })]),\n chat: response([channel('kimi-chat', 'acct-route-keys', 1, ['sk-shared'], {\n status: 'active',\n apiKeyConfigs: [{ key: 'sk-shared', enabled: true }],\n })]),\n responses: response([]),\n gemini: response([]),\n }\n\n const routes = buildUnifiedChannelsData(data).channels[0].protocolRoutes!\n expect(routes[0]).toMatchObject({\n kind: 'messages',\n apiKeys: [],\n apiKeyConfigs: [{ key: 'sk-shared', enabled: true }],\n disabledApiKeys: [disabled],\n })\n expect(routes[1]).toMatchObject({\n kind: 'chat',\n apiKeys: ['sk-shared'],\n apiKeyConfigs: [{ key: 'sk-shared', enabled: true }],\n disabledApiKeys: undefined,\n })\n expect(routeHasOnlyDisabledKeys(routes[0])).toBe(true)\n expect(routeHasOnlyDisabledKeys(routes[1])).toBe(false)\n })\n\n it('聚合账号合并各协议凭证并按全部路由计算状态', () => {\n const data: Record = {\n messages: response([channel('kimi-claude', 'acct-kimi', 0, [], {\n status: 'suspended',\n })]),\n chat: response([channel('kimi-chat', 'acct-kimi', 1, ['sk-kimi', 'sk-disabled'], {\n status: 'active',\n apiKeyConfigs: [{ key: 'sk-kimi', credentialUid: 'cred-kimi' }],\n disabledApiKeys: [{\n key: 'sk-disabled',\n reason: 'authentication_error',\n message: 'invalid key',\n disabledAt: '2026-07-28T00:00:00Z',\n }],\n })]),\n responses: response([]),\n gemini: response([]),\n }\n\n const [logicalChannel] = buildUnifiedChannelsData(data).channels\n expect(logicalChannel.apiKeys).toEqual(['sk-kimi', 'sk-disabled'])\n expect(logicalChannel.apiKeyConfigs).toEqual([\n { key: 'sk-kimi', credentialUid: 'cred-kimi' },\n ])\n expect(logicalChannel.disabledApiKeys).toEqual([{\n key: 'sk-disabled',\n reason: 'authentication_error',\n message: 'invalid key',\n disabledAt: '2026-07-28T00:00:00Z',\n }])\n expect(logicalChannel.status).toBe('partial')\n })\n\n it('保留各协议路由状态供聚合渠道恢复使用', () => {\n const data: Record = {\n messages: response([channel('mimo-main-claude', 'acct-main', 0, ['sk-a'], { status: 'suspended' })]),\n chat: response([channel('mimo-main-chat', 'acct-main', 1, ['sk-a'], { status: 'active' })]),\n responses: response([channel('mimo-main-codex', 'acct-main', 2, ['sk-a'], { status: 'disabled' })]),\n gemini: response([]),\n }\n\n const logicalChannel = buildUnifiedChannelsData(data).channels[0]\n expect(logicalChannel.protocolRoutes?.map(route => route.status)).toEqual([\n 'suspended',\n 'active',\n 'disabled',\n ])\n expect(resolveChannelRecoveryRoutes(logicalChannel)).toEqual([\n { kind: 'messages', index: 0, status: 'suspended' },\n { kind: 'chat', index: 1, status: 'active' },\n { kind: 'responses', index: 2, status: 'disabled' },\n ])\n })\n\n it('相同 provider 和名称下不同 accountUid 不应合并', () => {\n const data: Record = {\n messages: response([\n channel('mimo-main-claude', 'acct-a', 0, ['sk-a']),\n channel('mimo-main-claude', 'acct-b', 1, ['sk-b']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n\n expect(buildUnifiedChannelsData(data).channels).toHaveLength(2)\n })\n\n it('按 accountUid 聚合自定义自动托管的全部成功协议', () => {\n const custom = (name: string, index: number, serviceType: Channel['serviceType']): Channel =>\n channel(name, 'acct-fastaitoken', index, ['sk-fastaitoken'], { providerId: '', autoManaged: true, serviceType })\n const data: Record = {\n messages: response([{\n ...custom('fastaitoken-com-test-claude', 0, 'claude'),\n supportedModels: ['gpt-5.6-sol', 'gpt-5.6-terra'],\n }]),\n chat: response([{\n ...custom('fastaitoken-com-test-chat', 0, 'openai'),\n supportedModels: ['gpt-5.6-sol'],\n }]),\n responses: response([{\n ...custom('fastaitoken-com-test-codex', 0, 'responses'),\n supportedModels: ['codex-auto-review', 'gpt-5.6-sol'],\n }]),\n gemini: response([]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].name).toBe('fastaitoken-com-test')\n expect(result.channels[0].protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT', 'CODEX'])\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'chat', 'responses'])\n expect(Object.fromEntries(\n result.channels[0].protocolRoutes?.map(route => [route.kind, route.supportedModels]) ?? [],\n )).toEqual({\n messages: ['gpt-5.6-sol', 'gpt-5.6-terra'],\n chat: ['gpt-5.6-sol'],\n responses: ['codex-auto-review', 'gpt-5.6-sol'],\n })\n })\n\n it('按迁移后的 accountUid 聚合普通同站点渠道', () => {\n const data: Record = {\n messages: response([channel('ai-prism-messages', 'acct-prism', 38, ['sk-messages'], { autoManaged: false, serviceType: 'responses' })]),\n chat: response([]),\n responses: response([channel('ai-prism-responses', 'acct-prism', 30, ['sk-responses'], { autoManaged: false, serviceType: 'responses' })]),\n gemini: response([channel('prism-gemini', 'acct-prism', 9, ['sk-gemini'], { autoManaged: false, serviceType: 'gemini' })]),\n }\n\n const result = buildUnifiedChannelsData(data)\n expect(result.channels).toHaveLength(1)\n expect(result.channels[0].protocolRoutes?.map(item => item.kind)).toEqual(['messages', 'responses', 'gemini'])\n expect(result.channels[0].apiKeys).toEqual(['sk-messages', 'sk-responses', 'sk-gemini'])\n })\n\n it('协议标签只展示上游实际提供的 serviceType', () => {\n const data: Record = {\n messages: response([channel('volcengine-claude', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'claude',\n })]),\n chat: response([channel('volcengine-chat', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n responses: response([channel('volcengine-codex', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n gemini: response([channel('volcengine-gemini', 'acct-volcengine', 0, ['ark-key'], {\n providerId: 'volcengine',\n serviceType: 'openai',\n })]),\n }\n\n const [volcengine] = buildUnifiedChannelsData(data).channels\n expect(volcengine.protocolCapsules?.map(item => item.label)).toEqual(['CLAUDE', 'CHAT'])\n expect(volcengine.protocolRoutes).toHaveLength(4)\n expect(volcengine.protocolRoutes?.every(route => route.supportedModels === undefined)).toBe(true)\n })\n\n it('新增单协议渠道置顶后保持多协议账号的既有相对顺序', () => {\n const data: Record = {\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '', priority: 0 }),\n channel('volcengine-claude', 'acct-volcengine', 1, ['sk-volcengine'], { providerId: 'volcengine', priority: 1 }),\n channel('mimo-claude', 'acct-mimo', 2, ['sk-mimo'], { priority: 2 }),\n ]),\n chat: response([\n channel('volcengine-chat', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-chat', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('desktop-deepseek-chat', 'acct-deepseek', 34, ['sk-deepseek'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n responses: response([\n channel('volcengine-codex', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-codex', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n channel('aixoras-xanqfm', 'acct-aixoras', 2, ['sk-aixoras'], {\n autoManaged: false,\n providerId: '',\n priority: 1,\n }),\n ]),\n gemini: response([\n channel('volcengine-gemini', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine', priority: 0 }),\n channel('mimo-gemini', 'acct-mimo', 1, ['sk-mimo'], { priority: 1 }),\n ]),\n }\n\n const channels = buildUnifiedChannelsData(data).channels\n const sorted = [...channels].sort((a, b) => (a.priority ?? a.index) - (b.priority ?? b.index))\n\n expect(sorted.slice(0, 5).map(item => item.name)).toEqual([\n 'localhost-37zq4d',\n 'volcengine',\n 'mimo',\n 'desktop-deepseek-chat',\n 'aixoras-xanqfm',\n ])\n expect(channels.find(item => item.name === 'mimo')?.priority).toBe(1)\n })\n\n it('在列表头部插入渠道时保持既有逻辑渠道的展示 key 稳定', () => {\n const original: Record = {\n messages: response([\n channel('volcengine-claude', 'acct-volcengine', 0, ['sk-volcengine'], { providerId: 'volcengine' }),\n channel('mimo-claude', 'acct-mimo', 1, ['sk-mimo']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([]),\n }\n const withNewChannel: Record = {\n ...original,\n messages: response([\n channel('localhost-37zq4d', 'acct-local', 0, ['sk-local'], { providerId: '' }),\n ...original.messages.channels,\n ]),\n }\n\n const originalMimo = buildUnifiedChannelsData(original).channels.find(item => item.name === 'mimo')\n const nextMimo = buildUnifiedChannelsData(withNewChannel).channels.find(item => item.name === 'mimo')\n\n expect(nextMimo?.displayKey).toBe(originalMimo?.displayKey)\n })\n\n it('聚合逻辑渠道全部协议的最近请求活动', () => {\n const data: Record = {\n messages: response([channel('mimo-claude', 'acct-mimo', 0, ['sk-mimo'])]),\n chat: response([channel('mimo-chat', 'acct-mimo', 1, ['sk-mimo'])]),\n responses: response([]),\n gemini: response([]),\n }\n const [logicalChannel] = buildUnifiedChannelsData(data).channels\n const activity = (\n channelIndex: number,\n requestCount: number,\n successCount: number,\n failureCount: number,\n ): ChannelRecentActivity => ({\n channelIndex,\n segments: {\n 4: { requestCount, successCount, failureCount, inputTokens: requestCount * 10, outputTokens: requestCount * 2 },\n },\n totalSegs: 150,\n rpm: requestCount / 15,\n tpm: requestCount * 2 / 15,\n })\n\n const [merged] = buildUnifiedRecentActivity([logicalChannel], {\n messages: [activity(0, 2, 2, 0)],\n chat: [activity(1, 3, 2, 1)],\n responses: [],\n gemini: [],\n })\n\n expect(merged.channelIndex).toBe(0)\n expect(merged.routeKind).toBe('messages')\n expect(merged.segments[4]).toEqual({\n requestCount: 5,\n successCount: 4,\n failureCount: 1,\n inputTokens: 50,\n outputTokens: 10,\n })\n expect(merged.rpm).toBeCloseTo(5 / 15)\n expect(merged.tpm).toBeCloseTo(10 / 15)\n })\n})\n\ndescribe('buildUnifiedReorderPayloads', () => {\n it('priority 使用统一列表全局位次而非各协议组内名次', () => {\n const data: Record = {\n messages: response([\n channel('a-claude', 'acct-a', 0, ['sk-a']),\n channel('b-claude', 'acct-b', 1, ['sk-b']),\n ]),\n chat: response([]),\n responses: response([]),\n gemini: response([channel('g-gemini', 'acct-g', 0, ['sk-g'], { serviceType: 'gemini' })]),\n }\n const unified = buildUnifiedChannelsData(data).channels\n const a = unified.find(c => c.protocolRoutes?.some(r => r.kind === 'messages' && r.index === 0))!\n const b = unified.find(c => c.protocolRoutes?.some(r => r.kind === 'messages' && r.index === 1))!\n const g = unified.find(c => c.protocolRoutes?.some(r => r.kind === 'gemini'))!\n\n // 模拟拖拽后的统一列表顺序:a, g, b\n const payloads = buildUnifiedReorderPayloads([a, g, b])\n\n // messages:a 位次 1,b 位次 3(中间隔着 gemini 渠道)\n expect(payloads.get('messages')).toEqual({ order: [0, 1], priorities: [1, 3] })\n // gemini:g 位次 2。若按组内名次编号会得到 1,刷新后 min(priority) 会把 g 提到最前\n expect(payloads.get('gemini')).toEqual({ order: [0], priorities: [2] })\n expect(payloads.has('chat')).toBe(false)\n })\n\n it('无 protocolRoutes 的渠道不产生载荷', () => {\n const payloads = buildUnifiedReorderPayloads([{ name: 'plain', index: 0 } as Channel])\n expect(payloads.size).toBe(0)\n })\n})\n"}}, "backend-go/internal/handlers/responses/channels_advanced_test.go": {"f6977b2d05d47b726706c52e0ff5e873888ad2a1": {"t": 1785747458.0088723, "n": 5419, "h": "ce76564c05ee9353", "c": "package responses\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupResponsesConfigManager(t *testing.T, upstream []config.UpstreamConfig) *config.ConfigManager {\n\tt.Helper()\n\tcfg := config.Config{ResponsesUpstream: upstream}\n\tdata, err := json.MarshalIndent(cfg, \"\", \" \")\n\tif err != nil {\n\t\tt.Fatalf(\"序列化配置失败: %v\", err)\n\t}\n\ttmpFile := filepath.Join(t.TempDir(), \"config.json\")\n\tif err := os.WriteFile(tmpFile, data, 0644); err != nil {\n\t\tt.Fatalf(\"写入配置文件失败: %v\", err)\n\t}\n\tcm, err := config.NewConfigManager(tmpFile, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"创建配置管理器失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cm.Close() })\n\treturn cm\n}\n\nfunc TestBuildHealthCheckURLs_UseExistingVersionSuffix(t *testing.T) {\n\tif got := buildModelsURL(\"https://api.deepseek.com\"); got != \"https://api.deepseek.com/models\" {\n\t\tt.Fatalf(\"DeepSeek buildModelsURL() = %s\", got)\n\t}\n\tif got := buildMessagesURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/messages\" {\n\t\tt.Fatalf(\"buildMessagesURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() = %s\", got)\n\t}\n\tif got := buildGeminiModelsURL(\"https://api.example.com/codex/v1beta\"); got != \"https://api.example.com/codex/v1beta/models\" {\n\t\tt.Fatalf(\"buildGeminiModelsURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1#\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() with marker = %s\", got)\n\t}\n}\n\nfunc TestGetUpstreams_IncludesUnifiedStateFields(t *testing.T) {\n\tchannel := config.UpstreamConfig{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"claude\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tAPIKeys: []string{\"sk-1\"},\n\t\tStatus: \"suspended\",\n\t\tDisabledAPIKeys: []config.DisabledKeyInfo{{\n\t\t\tKey: \"sk-disabled\",\n\t\t\tReason: \"insufficient_balance\",\n\t\t\tMessage: \"no balance\",\n\t\t\tDisabledAt: \"2026-04-11T00:00:00Z\",\n\t\t}},\n\t}\n\t// 两个兼容开关已无手工字段:用可落盘的种子表达\"生效值为 true\"。\n\tchannel.SetCompatSeed(config.TraitPassbackReasoningContent, true)\n\tchannel.SetCompatSeed(config.TraitPassbackThinkingBlocks, true)\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{channel})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/channels\", GetUpstreams(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/channels\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200\", w.Code)\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]interface{} `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"status\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"status = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"adminState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"adminState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"effectiveState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"effectiveState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"runtimeState\"]; got != \"disabled_keys_present\" {\n\t\tt.Fatalf(\"runtimeState = %v, want disabled_keys_present\", got)\n\t}\n\tif got := resp.Channels[0][\"passbackReasoningContent\"]; got != true {\n\t\tt.Fatalf(\"passbackReasoningContent = %v, want true\", got)\n\t}\n\tif got := resp.Channels[0][\"passbackThinkingBlocks\"]; got != true {\n\t\tt.Fatalf(\"passbackThinkingBlocks = %v, want true\", got)\n\t}\n\tdisabledKeys, ok := resp.Channels[0][\"disabledApiKeys\"].([]interface{})\n\tif !ok || len(disabledKeys) != 1 {\n\t\tt.Fatalf(\"disabledApiKeys = %#v, want len 1\", resp.Channels[0][\"disabledApiKeys\"])\n\t}\n}\n\nfunc TestPingChannel_WithoutBaseURLReturnsError(t *testing.T) {\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"responses\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/ping/:id\", PingChannel(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/ping/0\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif got := resp[\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n\nfunc TestPingAllChannels_WithoutBaseURLMarksChannelError(t *testing.T) {\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"responses\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/ping\", PingAllChannels(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/ping\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp []map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp) != 1 {\n\t\tt.Fatalf(\"len(results) = %d, want 1\", len(resp))\n\t}\n\tif got := resp[0][\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n"}, "d971de1e20ffe917724faa91e964fed6adeb6a49": {"t": 1785759324.136211, "n": 5419, "h": "ce76564c05ee9353", "c": "package responses\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupResponsesConfigManager(t *testing.T, upstream []config.UpstreamConfig) *config.ConfigManager {\n\tt.Helper()\n\tcfg := config.Config{ResponsesUpstream: upstream}\n\tdata, err := json.MarshalIndent(cfg, \"\", \" \")\n\tif err != nil {\n\t\tt.Fatalf(\"序列化配置失败: %v\", err)\n\t}\n\ttmpFile := filepath.Join(t.TempDir(), \"config.json\")\n\tif err := os.WriteFile(tmpFile, data, 0644); err != nil {\n\t\tt.Fatalf(\"写入配置文件失败: %v\", err)\n\t}\n\tcm, err := config.NewConfigManager(tmpFile, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"创建配置管理器失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cm.Close() })\n\treturn cm\n}\n\nfunc TestBuildHealthCheckURLs_UseExistingVersionSuffix(t *testing.T) {\n\tif got := buildModelsURL(\"https://api.deepseek.com\"); got != \"https://api.deepseek.com/models\" {\n\t\tt.Fatalf(\"DeepSeek buildModelsURL() = %s\", got)\n\t}\n\tif got := buildMessagesURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/messages\" {\n\t\tt.Fatalf(\"buildMessagesURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() = %s\", got)\n\t}\n\tif got := buildGeminiModelsURL(\"https://api.example.com/codex/v1beta\"); got != \"https://api.example.com/codex/v1beta/models\" {\n\t\tt.Fatalf(\"buildGeminiModelsURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1#\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() with marker = %s\", got)\n\t}\n}\n\nfunc TestGetUpstreams_IncludesUnifiedStateFields(t *testing.T) {\n\tchannel := config.UpstreamConfig{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"claude\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tAPIKeys: []string{\"sk-1\"},\n\t\tStatus: \"suspended\",\n\t\tDisabledAPIKeys: []config.DisabledKeyInfo{{\n\t\t\tKey: \"sk-disabled\",\n\t\t\tReason: \"insufficient_balance\",\n\t\t\tMessage: \"no balance\",\n\t\t\tDisabledAt: \"2026-04-11T00:00:00Z\",\n\t\t}},\n\t}\n\t// 两个兼容开关已无手工字段:用可落盘的种子表达\"生效值为 true\"。\n\tchannel.SetCompatSeed(config.TraitPassbackReasoningContent, true)\n\tchannel.SetCompatSeed(config.TraitPassbackThinkingBlocks, true)\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{channel})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/channels\", GetUpstreams(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/channels\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200\", w.Code)\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]interface{} `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"status\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"status = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"adminState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"adminState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"effectiveState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"effectiveState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"runtimeState\"]; got != \"disabled_keys_present\" {\n\t\tt.Fatalf(\"runtimeState = %v, want disabled_keys_present\", got)\n\t}\n\tif got := resp.Channels[0][\"passbackReasoningContent\"]; got != true {\n\t\tt.Fatalf(\"passbackReasoningContent = %v, want true\", got)\n\t}\n\tif got := resp.Channels[0][\"passbackThinkingBlocks\"]; got != true {\n\t\tt.Fatalf(\"passbackThinkingBlocks = %v, want true\", got)\n\t}\n\tdisabledKeys, ok := resp.Channels[0][\"disabledApiKeys\"].([]interface{})\n\tif !ok || len(disabledKeys) != 1 {\n\t\tt.Fatalf(\"disabledApiKeys = %#v, want len 1\", resp.Channels[0][\"disabledApiKeys\"])\n\t}\n}\n\nfunc TestPingChannel_WithoutBaseURLReturnsError(t *testing.T) {\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"responses\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/ping/:id\", PingChannel(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/ping/0\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif got := resp[\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n\nfunc TestPingAllChannels_WithoutBaseURLMarksChannelError(t *testing.T) {\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"responses\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/ping\", PingAllChannels(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/ping\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp []map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp) != 1 {\n\t\tt.Fatalf(\"len(results) = %d, want 1\", len(resp))\n\t}\n\tif got := resp[0][\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n"}, "01e8421801c2c699d13f4ef638cda19f9af22414": {"t": 1785759325.942539, "n": 5372, "h": "cfcfeb3ecb363d47", "c": "package responses\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupResponsesConfigManager(t *testing.T, upstream []config.UpstreamConfig) *config.ConfigManager {\n\tt.Helper()\n\tcfg := config.Config{ResponsesUpstream: upstream}\n\tdata, err := json.MarshalIndent(cfg, \"\", \" \")\n\tif err != nil {\n\t\tt.Fatalf(\"序列化配置失败: %v\", err)\n\t}\n\ttmpFile := filepath.Join(t.TempDir(), \"config.json\")\n\tif err := os.WriteFile(tmpFile, data, 0644); err != nil {\n\t\tt.Fatalf(\"写入配置文件失败: %v\", err)\n\t}\n\tcm, err := config.NewConfigManager(tmpFile, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"创建配置管理器失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cm.Close() })\n\treturn cm\n}\n\nfunc TestBuildHealthCheckURLs_UseExistingVersionSuffix(t *testing.T) {\n\tif got := buildModelsURL(\"https://api.deepseek.com\"); got != \"https://api.deepseek.com/models\" {\n\t\tt.Fatalf(\"DeepSeek buildModelsURL() = %s\", got)\n\t}\n\tif got := buildMessagesURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/messages\" {\n\t\tt.Fatalf(\"buildMessagesURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() = %s\", got)\n\t}\n\tif got := buildGeminiModelsURL(\"https://api.example.com/codex/v1beta\"); got != \"https://api.example.com/codex/v1beta/models\" {\n\t\tt.Fatalf(\"buildGeminiModelsURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1#\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() with marker = %s\", got)\n\t}\n}\n\nfunc TestGetUpstreams_IncludesUnifiedStateFields(t *testing.T) {\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"claude\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tAPIKeys: []string{\"sk-1\"},\n\t\tStatus: \"suspended\",\n\t\tPassbackReasoningContent: config.BoolPtr(true),\n\t\tPassbackThinkingBlocks: config.BoolPtr(true),\n\t\tDisabledAPIKeys: []config.DisabledKeyInfo{{\n\t\t\tKey: \"sk-disabled\",\n\t\t\tReason: \"insufficient_balance\",\n\t\t\tMessage: \"no balance\",\n\t\t\tDisabledAt: \"2026-04-11T00:00:00Z\",\n\t\t}},\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/channels\", GetUpstreams(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/channels\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200\", w.Code)\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]interface{} `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"status\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"status = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"adminState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"adminState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"effectiveState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"effectiveState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"runtimeState\"]; got != \"disabled_keys_present\" {\n\t\tt.Fatalf(\"runtimeState = %v, want disabled_keys_present\", got)\n\t}\n\tif got := resp.Channels[0][\"passbackReasoningContent\"]; got != true {\n\t\tt.Fatalf(\"passbackReasoningContent = %v, want true\", got)\n\t}\n\tif got := resp.Channels[0][\"passbackThinkingBlocks\"]; got != true {\n\t\tt.Fatalf(\"passbackThinkingBlocks = %v, want true\", got)\n\t}\n\tdisabledKeys, ok := resp.Channels[0][\"disabledApiKeys\"].([]interface{})\n\tif !ok || len(disabledKeys) != 1 {\n\t\tt.Fatalf(\"disabledApiKeys = %#v, want len 1\", resp.Channels[0][\"disabledApiKeys\"])\n\t}\n}\n\nfunc TestPingChannel_WithoutBaseURLReturnsError(t *testing.T) {\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"responses\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/ping/:id\", PingChannel(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/ping/0\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif got := resp[\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n\nfunc TestPingAllChannels_WithoutBaseURLMarksChannelError(t *testing.T) {\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"responses\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/ping\", PingAllChannels(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/ping\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp []map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp) != 1 {\n\t\tt.Fatalf(\"len(results) = %d, want 1\", len(resp))\n\t}\n\tif got := resp[0][\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n"}, "6a6b2055828ba187fd85475333e90bf7166f183c": {"t": 1785759327.4328191, "n": 5340, "h": "0468c8ecd4a6d20d", "c": "package responses\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupResponsesConfigManager(t *testing.T, upstream []config.UpstreamConfig) *config.ConfigManager {\n\tt.Helper()\n\tcfg := config.Config{ResponsesUpstream: upstream}\n\tdata, err := json.MarshalIndent(cfg, \"\", \" \")\n\tif err != nil {\n\t\tt.Fatalf(\"序列化配置失败: %v\", err)\n\t}\n\ttmpFile := filepath.Join(t.TempDir(), \"config.json\")\n\tif err := os.WriteFile(tmpFile, data, 0644); err != nil {\n\t\tt.Fatalf(\"写入配置文件失败: %v\", err)\n\t}\n\tcm, err := config.NewConfigManager(tmpFile, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"创建配置管理器失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cm.Close() })\n\treturn cm\n}\n\nfunc TestBuildHealthCheckURLs_UseExistingVersionSuffix(t *testing.T) {\n\tif got := buildModelsURL(\"https://api.deepseek.com\"); got != \"https://api.deepseek.com/models\" {\n\t\tt.Fatalf(\"DeepSeek buildModelsURL() = %s\", got)\n\t}\n\tif got := buildMessagesURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/messages\" {\n\t\tt.Fatalf(\"buildMessagesURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() = %s\", got)\n\t}\n\tif got := buildGeminiModelsURL(\"https://api.example.com/codex/v1beta\"); got != \"https://api.example.com/codex/v1beta/models\" {\n\t\tt.Fatalf(\"buildGeminiModelsURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1#\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() with marker = %s\", got)\n\t}\n}\n\nfunc TestGetUpstreams_IncludesUnifiedStateFields(t *testing.T) {\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"claude\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tAPIKeys: []string{\"sk-1\"},\n\t\tStatus: \"suspended\",\n\t\tPassbackReasoningContent: true,\n\t\tPassbackThinkingBlocks: true,\n\t\tDisabledAPIKeys: []config.DisabledKeyInfo{{\n\t\t\tKey: \"sk-disabled\",\n\t\t\tReason: \"insufficient_balance\",\n\t\t\tMessage: \"no balance\",\n\t\t\tDisabledAt: \"2026-04-11T00:00:00Z\",\n\t\t}},\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/channels\", GetUpstreams(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/channels\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200\", w.Code)\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]interface{} `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"status\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"status = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"adminState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"adminState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"effectiveState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"effectiveState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"runtimeState\"]; got != \"disabled_keys_present\" {\n\t\tt.Fatalf(\"runtimeState = %v, want disabled_keys_present\", got)\n\t}\n\tif got := resp.Channels[0][\"passbackReasoningContent\"]; got != true {\n\t\tt.Fatalf(\"passbackReasoningContent = %v, want true\", got)\n\t}\n\tif got := resp.Channels[0][\"passbackThinkingBlocks\"]; got != true {\n\t\tt.Fatalf(\"passbackThinkingBlocks = %v, want true\", got)\n\t}\n\tdisabledKeys, ok := resp.Channels[0][\"disabledApiKeys\"].([]interface{})\n\tif !ok || len(disabledKeys) != 1 {\n\t\tt.Fatalf(\"disabledApiKeys = %#v, want len 1\", resp.Channels[0][\"disabledApiKeys\"])\n\t}\n}\n\nfunc TestPingChannel_WithoutBaseURLReturnsError(t *testing.T) {\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"responses\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/ping/:id\", PingChannel(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/ping/0\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif got := resp[\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n\nfunc TestPingAllChannels_WithoutBaseURLMarksChannelError(t *testing.T) {\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"responses\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/ping\", PingAllChannels(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/ping\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp []map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp) != 1 {\n\t\tt.Fatalf(\"len(results) = %d, want 1\", len(resp))\n\t}\n\tif got := resp[0][\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n"}, "5d2ea67008b8062dd3b4f7104a16827e50c393b3": {"t": 1785759328.9336681, "n": 5336, "h": "518028a7700939be", "c": "package responses\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupResponsesConfigManager(t *testing.T, upstream []config.UpstreamConfig) *config.ConfigManager {\n\tt.Helper()\n\tcfg := config.Config{ResponsesUpstream: upstream}\n\tdata, err := json.MarshalIndent(cfg, \"\", \" \")\n\tif err != nil {\n\t\tt.Fatalf(\"序列化配置失败: %v\", err)\n\t}\n\ttmpFile := filepath.Join(t.TempDir(), \"config.json\")\n\tif err := os.WriteFile(tmpFile, data, 0644); err != nil {\n\t\tt.Fatalf(\"写入配置文件失败: %v\", err)\n\t}\n\tcm, err := config.NewConfigManager(tmpFile, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"创建配置管理器失败: %v\", err)\n\t}\n\tt.Cleanup(func() { cm.Close() })\n\treturn cm\n}\n\nfunc TestBuildHealthCheckURLs_UseExistingVersionSuffix(t *testing.T) {\n\tif got := buildModelsURL(\"https://api.deepseek.com\"); got != \"https://api.deepseek.com/models\" {\n\t\tt.Fatalf(\"DeepSeek buildModelsURL() = %s\", got)\n\t}\n\tif got := buildMessagesURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/messages\" {\n\t\tt.Fatalf(\"buildMessagesURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() = %s\", got)\n\t}\n\tif got := buildGeminiModelsURL(\"https://api.example.com/codex/v1beta\"); got != \"https://api.example.com/codex/v1beta/models\" {\n\t\tt.Fatalf(\"buildGeminiModelsURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1#\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() with marker = %s\", got)\n\t}\n}\n\nfunc TestGetUpstreams_IncludesUnifiedStateFields(t *testing.T) {\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"claude\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tAPIKeys: []string{\"sk-1\"},\n\t\tStatus: \"suspended\",\n\t\tPassbackReasoningContent: true,\n\t\tPassbackThinkingBlocks: true,\n\t\tDisabledAPIKeys: []config.DisabledKeyInfo{{\n\t\t\tKey: \"sk-disabled\",\n\t\t\tReason: \"insufficient_balance\",\n\t\t\tMessage: \"no balance\",\n\t\t\tDisabledAt: \"2026-04-11T00:00:00Z\",\n\t\t}},\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/channels\", GetUpstreams(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/channels\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200\", w.Code)\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]interface{} `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"status\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"status = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"adminState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"adminState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"effectiveState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"effectiveState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"runtimeState\"]; got != \"disabled_keys_present\" {\n\t\tt.Fatalf(\"runtimeState = %v, want disabled_keys_present\", got)\n\t}\n\tif got := resp.Channels[0][\"passbackReasoningContent\"]; got != true {\n\t\tt.Fatalf(\"passbackReasoningContent = %v, want true\", got)\n\t}\n\tif got := resp.Channels[0][\"passbackThinkingBlocks\"]; got != true {\n\t\tt.Fatalf(\"passbackThinkingBlocks = %v, want true\", got)\n\t}\n\tdisabledKeys, ok := resp.Channels[0][\"disabledApiKeys\"].([]interface{})\n\tif !ok || len(disabledKeys) != 1 {\n\t\tt.Fatalf(\"disabledApiKeys = %#v, want len 1\", resp.Channels[0][\"disabledApiKeys\"])\n\t}\n}\n\nfunc TestPingChannel_WithoutBaseURLReturnsError(t *testing.T) {\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"responses\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/ping/:id\", PingChannel(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/ping/0\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif got := resp[\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n\nfunc TestPingAllChannels_WithoutBaseURLMarksChannelError(t *testing.T) {\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"responses\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/ping\", PingAllChannels(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/ping\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp []map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp) != 1 {\n\t\tt.Fatalf(\"len(results) = %d, want 1\", len(resp))\n\t}\n\tif got := resp[0][\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n"}, "42375acbe84df03ecc7bfbf3dcd7e61e1378b8bf": {"t": 1785759330.564866, "n": 5185, "h": "5f7bbd9b0be6ac46", "c": "package responses\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupResponsesConfigManager(t *testing.T, upstream []config.UpstreamConfig) *config.ConfigManager {\n\tt.Helper()\n\tcfg := config.Config{ResponsesUpstream: upstream}\n\tdata, err := json.MarshalIndent(cfg, \"\", \" \")\n\tif err != nil {\n\t\tt.Fatalf(\"序列化配置失败: %v\", err)\n\t}\n\ttmpFile := filepath.Join(t.TempDir(), \"config.json\")\n\tif err := os.WriteFile(tmpFile, data, 0644); err != nil {\n\t\tt.Fatalf(\"写入配置文件失败: %v\", err)\n\t}\n\tcm, err := config.NewConfigManager(tmpFile, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"创建配置管理器失败: %v\", err)\n\t}\n\tt.Cleanup(func() { cm.Close() })\n\treturn cm\n}\n\nfunc TestBuildHealthCheckURLs_UseExistingVersionSuffix(t *testing.T) {\n\tif got := buildMessagesURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/messages\" {\n\t\tt.Fatalf(\"buildMessagesURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() = %s\", got)\n\t}\n\tif got := buildGeminiModelsURL(\"https://api.example.com/codex/v1beta\"); got != \"https://api.example.com/codex/v1beta/models\" {\n\t\tt.Fatalf(\"buildGeminiModelsURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1#\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() with marker = %s\", got)\n\t}\n}\n\nfunc TestGetUpstreams_IncludesUnifiedStateFields(t *testing.T) {\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"claude\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tAPIKeys: []string{\"sk-1\"},\n\t\tStatus: \"suspended\",\n\t\tPassbackReasoningContent: true,\n\t\tPassbackThinkingBlocks: true,\n\t\tDisabledAPIKeys: []config.DisabledKeyInfo{{\n\t\t\tKey: \"sk-disabled\",\n\t\t\tReason: \"insufficient_balance\",\n\t\t\tMessage: \"no balance\",\n\t\t\tDisabledAt: \"2026-04-11T00:00:00Z\",\n\t\t}},\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/channels\", GetUpstreams(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/channels\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200\", w.Code)\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]interface{} `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"status\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"status = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"adminState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"adminState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"effectiveState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"effectiveState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"runtimeState\"]; got != \"disabled_keys_present\" {\n\t\tt.Fatalf(\"runtimeState = %v, want disabled_keys_present\", got)\n\t}\n\tif got := resp.Channels[0][\"passbackReasoningContent\"]; got != true {\n\t\tt.Fatalf(\"passbackReasoningContent = %v, want true\", got)\n\t}\n\tif got := resp.Channels[0][\"passbackThinkingBlocks\"]; got != true {\n\t\tt.Fatalf(\"passbackThinkingBlocks = %v, want true\", got)\n\t}\n\tdisabledKeys, ok := resp.Channels[0][\"disabledApiKeys\"].([]interface{})\n\tif !ok || len(disabledKeys) != 1 {\n\t\tt.Fatalf(\"disabledApiKeys = %#v, want len 1\", resp.Channels[0][\"disabledApiKeys\"])\n\t}\n}\n\nfunc TestPingChannel_WithoutBaseURLReturnsError(t *testing.T) {\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"responses\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/ping/:id\", PingChannel(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/ping/0\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif got := resp[\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n\nfunc TestPingAllChannels_WithoutBaseURLMarksChannelError(t *testing.T) {\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"responses\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/ping\", PingAllChannels(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/ping\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp []map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp) != 1 {\n\t\tt.Fatalf(\"len(results) = %d, want 1\", len(resp))\n\t}\n\tif got := resp[0][\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n"}, "70969985d98c17258c9c8b8cbd0d22845bca11da": {"t": 1785759332.107818, "n": 5181, "h": "9a4c1d72097ae029", "c": "package responses\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupResponsesConfigManager(t *testing.T, upstream []config.UpstreamConfig) *config.ConfigManager {\n\tt.Helper()\n\tcfg := config.Config{ResponsesUpstream: upstream}\n\tdata, err := json.MarshalIndent(cfg, \"\", \" \")\n\tif err != nil {\n\t\tt.Fatalf(\"序列化配置失败: %v\", err)\n\t}\n\ttmpFile := filepath.Join(t.TempDir(), \"config.json\")\n\tif err := os.WriteFile(tmpFile, data, 0644); err != nil {\n\t\tt.Fatalf(\"写入配置文件失败: %v\", err)\n\t}\n\tcm, err := config.NewConfigManager(tmpFile)\n\tif err != nil {\n\t\tt.Fatalf(\"创建配置管理器失败: %v\", err)\n\t}\n\tt.Cleanup(func() { cm.Close() })\n\treturn cm\n}\n\nfunc TestBuildHealthCheckURLs_UseExistingVersionSuffix(t *testing.T) {\n\tif got := buildMessagesURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/messages\" {\n\t\tt.Fatalf(\"buildMessagesURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() = %s\", got)\n\t}\n\tif got := buildGeminiModelsURL(\"https://api.example.com/codex/v1beta\"); got != \"https://api.example.com/codex/v1beta/models\" {\n\t\tt.Fatalf(\"buildGeminiModelsURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1#\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() with marker = %s\", got)\n\t}\n}\n\nfunc TestGetUpstreams_IncludesUnifiedStateFields(t *testing.T) {\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"claude\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tAPIKeys: []string{\"sk-1\"},\n\t\tStatus: \"suspended\",\n\t\tPassbackReasoningContent: true,\n\t\tPassbackThinkingBlocks: true,\n\t\tDisabledAPIKeys: []config.DisabledKeyInfo{{\n\t\t\tKey: \"sk-disabled\",\n\t\t\tReason: \"insufficient_balance\",\n\t\t\tMessage: \"no balance\",\n\t\t\tDisabledAt: \"2026-04-11T00:00:00Z\",\n\t\t}},\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/channels\", GetUpstreams(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/channels\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200\", w.Code)\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]interface{} `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"status\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"status = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"adminState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"adminState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"effectiveState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"effectiveState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"runtimeState\"]; got != \"disabled_keys_present\" {\n\t\tt.Fatalf(\"runtimeState = %v, want disabled_keys_present\", got)\n\t}\n\tif got := resp.Channels[0][\"passbackReasoningContent\"]; got != true {\n\t\tt.Fatalf(\"passbackReasoningContent = %v, want true\", got)\n\t}\n\tif got := resp.Channels[0][\"passbackThinkingBlocks\"]; got != true {\n\t\tt.Fatalf(\"passbackThinkingBlocks = %v, want true\", got)\n\t}\n\tdisabledKeys, ok := resp.Channels[0][\"disabledApiKeys\"].([]interface{})\n\tif !ok || len(disabledKeys) != 1 {\n\t\tt.Fatalf(\"disabledApiKeys = %#v, want len 1\", resp.Channels[0][\"disabledApiKeys\"])\n\t}\n}\n\nfunc TestPingChannel_WithoutBaseURLReturnsError(t *testing.T) {\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"responses\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/ping/:id\", PingChannel(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/ping/0\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif got := resp[\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n\nfunc TestPingAllChannels_WithoutBaseURLMarksChannelError(t *testing.T) {\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"responses\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/ping\", PingAllChannels(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/ping\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp []map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp) != 1 {\n\t\tt.Fatalf(\"len(results) = %d, want 1\", len(resp))\n\t}\n\tif got := resp[0][\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n"}, "8b9bc6b280fb9b0dd8348e808d46d3a1606c9cd5": {"t": 1785759333.961605, "n": 4787, "h": "91e33fd462efd9a5", "c": "package responses\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupResponsesConfigManager(t *testing.T, upstream []config.UpstreamConfig) *config.ConfigManager {\n\tt.Helper()\n\tcfg := config.Config{ResponsesUpstream: upstream}\n\tdata, err := json.MarshalIndent(cfg, \"\", \" \")\n\tif err != nil {\n\t\tt.Fatalf(\"序列化配置失败: %v\", err)\n\t}\n\ttmpFile := filepath.Join(t.TempDir(), \"config.json\")\n\tif err := os.WriteFile(tmpFile, data, 0644); err != nil {\n\t\tt.Fatalf(\"写入配置文件失败: %v\", err)\n\t}\n\tcm, err := config.NewConfigManager(tmpFile)\n\tif err != nil {\n\t\tt.Fatalf(\"创建配置管理器失败: %v\", err)\n\t}\n\tt.Cleanup(func() { cm.Close() })\n\treturn cm\n}\n\nfunc TestBuildHealthCheckURLs_UseExistingVersionSuffix(t *testing.T) {\n\tif got := buildMessagesURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/messages\" {\n\t\tt.Fatalf(\"buildMessagesURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() = %s\", got)\n\t}\n\tif got := buildGeminiModelsURL(\"https://api.example.com/codex/v1beta\"); got != \"https://api.example.com/codex/v1beta/models\" {\n\t\tt.Fatalf(\"buildGeminiModelsURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1#\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() with marker = %s\", got)\n\t}\n}\n\nfunc TestGetUpstreams_IncludesUnifiedStateFields(t *testing.T) {\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"responses\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tAPIKeys: []string{\"sk-1\"},\n\t\tStatus: \"suspended\",\n\t\tDisabledAPIKeys: []config.DisabledKeyInfo{{\n\t\t\tKey: \"sk-disabled\",\n\t\t\tReason: \"insufficient_balance\",\n\t\t\tMessage: \"no balance\",\n\t\t\tDisabledAt: \"2026-04-11T00:00:00Z\",\n\t\t}},\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/channels\", GetUpstreams(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/channels\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200\", w.Code)\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]interface{} `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"status\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"status = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"adminState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"adminState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"effectiveState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"effectiveState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"runtimeState\"]; got != \"disabled_keys_present\" {\n\t\tt.Fatalf(\"runtimeState = %v, want disabled_keys_present\", got)\n\t}\n\tdisabledKeys, ok := resp.Channels[0][\"disabledApiKeys\"].([]interface{})\n\tif !ok || len(disabledKeys) != 1 {\n\t\tt.Fatalf(\"disabledApiKeys = %#v, want len 1\", resp.Channels[0][\"disabledApiKeys\"])\n\t}\n}\n\nfunc TestPingChannel_WithoutBaseURLReturnsError(t *testing.T) {\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"responses\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/ping/:id\", PingChannel(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/ping/0\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif got := resp[\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n\nfunc TestPingAllChannels_WithoutBaseURLMarksChannelError(t *testing.T) {\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"responses\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/ping\", PingAllChannels(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/ping\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp []map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp) != 1 {\n\t\tt.Fatalf(\"len(results) = %d, want 1\", len(resp))\n\t}\n\tif got := resp[0][\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n"}, "3e4861fce509b5a2a69c40894daf4371f91ea276": {"t": 1785759335.7308223, "n": 2821, "h": "a1b441d2c38acc29", "c": "package responses\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupResponsesConfigManager(t *testing.T, upstream []config.UpstreamConfig) *config.ConfigManager {\n\tt.Helper()\n\tcfg := config.Config{ResponsesUpstream: upstream}\n\tdata, err := json.MarshalIndent(cfg, \"\", \" \")\n\tif err != nil {\n\t\tt.Fatalf(\"序列化配置失败: %v\", err)\n\t}\n\ttmpFile := filepath.Join(t.TempDir(), \"config.json\")\n\tif err := os.WriteFile(tmpFile, data, 0644); err != nil {\n\t\tt.Fatalf(\"写入配置文件失败: %v\", err)\n\t}\n\tcm, err := config.NewConfigManager(tmpFile)\n\tif err != nil {\n\t\tt.Fatalf(\"创建配置管理器失败: %v\", err)\n\t}\n\tt.Cleanup(func() { cm.Close() })\n\treturn cm\n}\n\nfunc TestBuildHealthCheckURLs_UseExistingVersionSuffix(t *testing.T) {\n\tif got := buildMessagesURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/messages\" {\n\t\tt.Fatalf(\"buildMessagesURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() = %s\", got)\n\t}\n\tif got := buildGeminiModelsURL(\"https://api.example.com/codex/v1beta\"); got != \"https://api.example.com/codex/v1beta/models\" {\n\t\tt.Fatalf(\"buildGeminiModelsURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1#\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() with marker = %s\", got)\n\t}\n}\n\nfunc TestGetUpstreams_IncludesAdvancedOptionFields(t *testing.T) {\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"responses\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tAPIKeys: []string{\"sk-1\"},\n\t\tModelMapping: map[string]string{\"gpt-5\": \"gpt-5.2\"},\n\t\tReasoningMapping: map[string]string{\"gpt-5\": \"high\"},\n\t\tTextVerbosity: \"high\",\n\t\tFastMode: true,\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/channels\", GetUpstreams(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/channels\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200\", w.Code)\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]interface{} `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tch := resp.Channels[0]\n\tif ch[\"textVerbosity\"] != \"high\" {\n\t\tt.Fatalf(\"textVerbosity = %v, want high\", ch[\"textVerbosity\"])\n\t}\n\tif ch[\"fastMode\"] != true {\n\t\tt.Fatalf(\"fastMode = %v, want true\", ch[\"fastMode\"])\n\t}\n\trm, ok := ch[\"reasoningMapping\"].(map[string]interface{})\n\tif !ok || rm[\"gpt-5\"] != \"high\" {\n\t\tt.Fatalf(\"reasoningMapping = %#v, want gpt-5=high\", ch[\"reasoningMapping\"])\n\t}\n}\n"}, "f7804d683cacbc7284a4e0bda1ecd12991994c84": {"t": 1785759337.2598705, "n": 2076, "h": "3b93e4b9aabb5fe3", "c": "package responses\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupResponsesConfigManager(t *testing.T, upstream []config.UpstreamConfig) *config.ConfigManager {\n\tt.Helper()\n\tcfg := config.Config{ResponsesUpstream: upstream}\n\tdata, err := json.MarshalIndent(cfg, \"\", \" \")\n\tif err != nil {\n\t\tt.Fatalf(\"序列化配置失败: %v\", err)\n\t}\n\ttmpFile := filepath.Join(t.TempDir(), \"config.json\")\n\tif err := os.WriteFile(tmpFile, data, 0644); err != nil {\n\t\tt.Fatalf(\"写入配置文件失败: %v\", err)\n\t}\n\tcm, err := config.NewConfigManager(tmpFile)\n\tif err != nil {\n\t\tt.Fatalf(\"创建配置管理器失败: %v\", err)\n\t}\n\tt.Cleanup(func() { cm.Close() })\n\treturn cm\n}\n\nfunc TestGetUpstreams_IncludesAdvancedOptionFields(t *testing.T) {\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"responses\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tAPIKeys: []string{\"sk-1\"},\n\t\tModelMapping: map[string]string{\"gpt-5\": \"gpt-5.2\"},\n\t\tReasoningMapping: map[string]string{\"gpt-5\": \"high\"},\n\t\tTextVerbosity: \"high\",\n\t\tFastMode: true,\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/channels\", GetUpstreams(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/channels\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200\", w.Code)\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]interface{} `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tch := resp.Channels[0]\n\tif ch[\"textVerbosity\"] != \"high\" {\n\t\tt.Fatalf(\"textVerbosity = %v, want high\", ch[\"textVerbosity\"])\n\t}\n\tif ch[\"fastMode\"] != true {\n\t\tt.Fatalf(\"fastMode = %v, want true\", ch[\"fastMode\"])\n\t}\n\trm, ok := ch[\"reasoningMapping\"].(map[string]interface{})\n\tif !ok || rm[\"gpt-5\"] != \"high\" {\n\t\tt.Fatalf(\"reasoningMapping = %#v, want gpt-5=high\", ch[\"reasoningMapping\"])\n\t}\n}\n"}, "71b842e3e8300d0b9329446af03a39c498580b55": {"t": 1786068175.250567, "n": 5419, "h": "ce76564c05ee9353", "c": "package responses\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupResponsesConfigManager(t *testing.T, upstream []config.UpstreamConfig) *config.ConfigManager {\n\tt.Helper()\n\tcfg := config.Config{ResponsesUpstream: upstream}\n\tdata, err := json.MarshalIndent(cfg, \"\", \" \")\n\tif err != nil {\n\t\tt.Fatalf(\"序列化配置失败: %v\", err)\n\t}\n\ttmpFile := filepath.Join(t.TempDir(), \"config.json\")\n\tif err := os.WriteFile(tmpFile, data, 0644); err != nil {\n\t\tt.Fatalf(\"写入配置文件失败: %v\", err)\n\t}\n\tcm, err := config.NewConfigManager(tmpFile, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"创建配置管理器失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cm.Close() })\n\treturn cm\n}\n\nfunc TestBuildHealthCheckURLs_UseExistingVersionSuffix(t *testing.T) {\n\tif got := buildModelsURL(\"https://api.deepseek.com\"); got != \"https://api.deepseek.com/models\" {\n\t\tt.Fatalf(\"DeepSeek buildModelsURL() = %s\", got)\n\t}\n\tif got := buildMessagesURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/messages\" {\n\t\tt.Fatalf(\"buildMessagesURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() = %s\", got)\n\t}\n\tif got := buildGeminiModelsURL(\"https://api.example.com/codex/v1beta\"); got != \"https://api.example.com/codex/v1beta/models\" {\n\t\tt.Fatalf(\"buildGeminiModelsURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1#\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() with marker = %s\", got)\n\t}\n}\n\nfunc TestGetUpstreams_IncludesUnifiedStateFields(t *testing.T) {\n\tchannel := config.UpstreamConfig{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"claude\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tAPIKeys: []string{\"sk-1\"},\n\t\tStatus: \"suspended\",\n\t\tDisabledAPIKeys: []config.DisabledKeyInfo{{\n\t\t\tKey: \"sk-disabled\",\n\t\t\tReason: \"insufficient_balance\",\n\t\t\tMessage: \"no balance\",\n\t\t\tDisabledAt: \"2026-04-11T00:00:00Z\",\n\t\t}},\n\t}\n\t// 两个兼容开关已无手工字段:用可落盘的种子表达\"生效值为 true\"。\n\tchannel.SetCompatSeed(config.TraitPassbackReasoningContent, true)\n\tchannel.SetCompatSeed(config.TraitPassbackThinkingBlocks, true)\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{channel})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/channels\", GetUpstreams(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/channels\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200\", w.Code)\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]interface{} `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"status\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"status = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"adminState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"adminState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"effectiveState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"effectiveState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"runtimeState\"]; got != \"disabled_keys_present\" {\n\t\tt.Fatalf(\"runtimeState = %v, want disabled_keys_present\", got)\n\t}\n\tif got := resp.Channels[0][\"passbackReasoningContent\"]; got != true {\n\t\tt.Fatalf(\"passbackReasoningContent = %v, want true\", got)\n\t}\n\tif got := resp.Channels[0][\"passbackThinkingBlocks\"]; got != true {\n\t\tt.Fatalf(\"passbackThinkingBlocks = %v, want true\", got)\n\t}\n\tdisabledKeys, ok := resp.Channels[0][\"disabledApiKeys\"].([]interface{})\n\tif !ok || len(disabledKeys) != 1 {\n\t\tt.Fatalf(\"disabledApiKeys = %#v, want len 1\", resp.Channels[0][\"disabledApiKeys\"])\n\t}\n}\n\nfunc TestPingChannel_WithoutBaseURLReturnsError(t *testing.T) {\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"responses\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/ping/:id\", PingChannel(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/ping/0\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif got := resp[\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n\nfunc TestPingAllChannels_WithoutBaseURLMarksChannelError(t *testing.T) {\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"responses\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/ping\", PingAllChannels(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/ping\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp []map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp) != 1 {\n\t\tt.Fatalf(\"len(results) = %d, want 1\", len(resp))\n\t}\n\tif got := resp[0][\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n"}, "8cb1f08da90e4858885f246ddadd488390fdba08": {"t": 1786068621.2007449, "n": 5419, "h": "ce76564c05ee9353", "c": "package responses\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupResponsesConfigManager(t *testing.T, upstream []config.UpstreamConfig) *config.ConfigManager {\n\tt.Helper()\n\tcfg := config.Config{ResponsesUpstream: upstream}\n\tdata, err := json.MarshalIndent(cfg, \"\", \" \")\n\tif err != nil {\n\t\tt.Fatalf(\"序列化配置失败: %v\", err)\n\t}\n\ttmpFile := filepath.Join(t.TempDir(), \"config.json\")\n\tif err := os.WriteFile(tmpFile, data, 0644); err != nil {\n\t\tt.Fatalf(\"写入配置文件失败: %v\", err)\n\t}\n\tcm, err := config.NewConfigManager(tmpFile, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"创建配置管理器失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cm.Close() })\n\treturn cm\n}\n\nfunc TestBuildHealthCheckURLs_UseExistingVersionSuffix(t *testing.T) {\n\tif got := buildModelsURL(\"https://api.deepseek.com\"); got != \"https://api.deepseek.com/models\" {\n\t\tt.Fatalf(\"DeepSeek buildModelsURL() = %s\", got)\n\t}\n\tif got := buildMessagesURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/messages\" {\n\t\tt.Fatalf(\"buildMessagesURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() = %s\", got)\n\t}\n\tif got := buildGeminiModelsURL(\"https://api.example.com/codex/v1beta\"); got != \"https://api.example.com/codex/v1beta/models\" {\n\t\tt.Fatalf(\"buildGeminiModelsURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1#\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() with marker = %s\", got)\n\t}\n}\n\nfunc TestGetUpstreams_IncludesUnifiedStateFields(t *testing.T) {\n\tchannel := config.UpstreamConfig{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"claude\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tAPIKeys: []string{\"sk-1\"},\n\t\tStatus: \"suspended\",\n\t\tDisabledAPIKeys: []config.DisabledKeyInfo{{\n\t\t\tKey: \"sk-disabled\",\n\t\t\tReason: \"insufficient_balance\",\n\t\t\tMessage: \"no balance\",\n\t\t\tDisabledAt: \"2026-04-11T00:00:00Z\",\n\t\t}},\n\t}\n\t// 两个兼容开关已无手工字段:用可落盘的种子表达\"生效值为 true\"。\n\tchannel.SetCompatSeed(config.TraitPassbackReasoningContent, true)\n\tchannel.SetCompatSeed(config.TraitPassbackThinkingBlocks, true)\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{channel})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/channels\", GetUpstreams(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/channels\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200\", w.Code)\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]interface{} `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"status\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"status = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"adminState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"adminState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"effectiveState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"effectiveState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"runtimeState\"]; got != \"disabled_keys_present\" {\n\t\tt.Fatalf(\"runtimeState = %v, want disabled_keys_present\", got)\n\t}\n\tif got := resp.Channels[0][\"passbackReasoningContent\"]; got != true {\n\t\tt.Fatalf(\"passbackReasoningContent = %v, want true\", got)\n\t}\n\tif got := resp.Channels[0][\"passbackThinkingBlocks\"]; got != true {\n\t\tt.Fatalf(\"passbackThinkingBlocks = %v, want true\", got)\n\t}\n\tdisabledKeys, ok := resp.Channels[0][\"disabledApiKeys\"].([]interface{})\n\tif !ok || len(disabledKeys) != 1 {\n\t\tt.Fatalf(\"disabledApiKeys = %#v, want len 1\", resp.Channels[0][\"disabledApiKeys\"])\n\t}\n}\n\nfunc TestPingChannel_WithoutBaseURLReturnsError(t *testing.T) {\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"responses\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/ping/:id\", PingChannel(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/ping/0\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif got := resp[\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n\nfunc TestPingAllChannels_WithoutBaseURLMarksChannelError(t *testing.T) {\n\tcm := setupResponsesConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"resp-ch\",\n\t\tServiceType: \"responses\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/responses/ping\", PingAllChannels(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/responses/ping\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp []map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp) != 1 {\n\t\tt.Fatalf(\"len(results) = %d, want 1\", len(resp))\n\t}\n\tif got := resp[0][\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n"}}, "backend-go/internal/handlers/chat/channels_advanced_test.go": {"f6977b2d05d47b726706c52e0ff5e873888ad2a1": {"t": 1785747460.095931, "n": 4917, "h": "2b811f88fc288c8c", "c": "package chat\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupChatConfigManager(t *testing.T, upstream []config.UpstreamConfig) *config.ConfigManager {\n\tt.Helper()\n\tcfg := config.Config{ChatUpstream: upstream}\n\tdata, err := json.MarshalIndent(cfg, \"\", \" \")\n\tif err != nil {\n\t\tt.Fatalf(\"序列化配置失败: %v\", err)\n\t}\n\ttmpFile := filepath.Join(t.TempDir(), \"config.json\")\n\tif err := os.WriteFile(tmpFile, data, 0644); err != nil {\n\t\tt.Fatalf(\"写入配置文件失败: %v\", err)\n\t}\n\tcm, err := config.NewConfigManager(tmpFile, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"创建配置管理器失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cm.Close() })\n\treturn cm\n}\n\nfunc TestBuildHealthCheckURLs_UseExistingVersionSuffix(t *testing.T) {\n\tif got := buildModelsURL(\"https://api.deepseek.com\"); got != \"https://api.deepseek.com/models\" {\n\t\tt.Fatalf(\"DeepSeek buildModelsURL() = %s\", got)\n\t}\n\tif got := buildMessagesURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/messages\" {\n\t\tt.Fatalf(\"buildMessagesURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1#\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() with marker = %s\", got)\n\t}\n}\n\nfunc TestGetUpstreams_IncludesUnifiedStateFields(t *testing.T) {\n\tchannel := config.UpstreamConfig{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tAPIKeys: []string{\"sk-1\"},\n\t\tStatus: \"suspended\",\n\t\tDisabledAPIKeys: []config.DisabledKeyInfo{{\n\t\t\tKey: \"sk-disabled\",\n\t\t\tReason: \"insufficient_balance\",\n\t\t\tMessage: \"no balance\",\n\t\t\tDisabledAt: \"2026-04-11T00:00:00Z\",\n\t\t}},\n\t}\n\t// 该兼容开关已无手工字段:用可持久化的种子模拟\"生效值为 true\",学习结论不落盘。\n\tchannel.SetCompatSeed(config.TraitNormalizeNonstandardChatRoles, true)\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{channel})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/channels\", GetUpstreams(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/channels\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200\", w.Code)\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]interface{} `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"status\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"status = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"adminState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"adminState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"effectiveState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"effectiveState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"runtimeState\"]; got != \"disabled_keys_present\" {\n\t\tt.Fatalf(\"runtimeState = %v, want disabled_keys_present\", got)\n\t}\n\tif got := resp.Channels[0][\"normalizeNonstandardChatRoles\"]; got != true {\n\t\tt.Fatalf(\"normalizeNonstandardChatRoles = %v, want true\", got)\n\t}\n}\n\nfunc TestPingChannel_WithoutBaseURLReturnsError(t *testing.T) {\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/ping/:id\", PingChannel(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/ping/0\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK && w.Code != http.StatusBadRequest {\n\t\tt.Fatalf(\"status = %d, want 200 or 400, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif got := resp[\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n\nfunc TestPingAllChannels_WithoutBaseURLMarksChannelError(t *testing.T) {\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/ping\", PingAllChannels(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/ping\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]any `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n"}, "d971de1e20ffe917724faa91e964fed6adeb6a49": {"t": 1785759331.9881694, "n": 4917, "h": "2b811f88fc288c8c", "c": "package chat\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupChatConfigManager(t *testing.T, upstream []config.UpstreamConfig) *config.ConfigManager {\n\tt.Helper()\n\tcfg := config.Config{ChatUpstream: upstream}\n\tdata, err := json.MarshalIndent(cfg, \"\", \" \")\n\tif err != nil {\n\t\tt.Fatalf(\"序列化配置失败: %v\", err)\n\t}\n\ttmpFile := filepath.Join(t.TempDir(), \"config.json\")\n\tif err := os.WriteFile(tmpFile, data, 0644); err != nil {\n\t\tt.Fatalf(\"写入配置文件失败: %v\", err)\n\t}\n\tcm, err := config.NewConfigManager(tmpFile, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"创建配置管理器失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cm.Close() })\n\treturn cm\n}\n\nfunc TestBuildHealthCheckURLs_UseExistingVersionSuffix(t *testing.T) {\n\tif got := buildModelsURL(\"https://api.deepseek.com\"); got != \"https://api.deepseek.com/models\" {\n\t\tt.Fatalf(\"DeepSeek buildModelsURL() = %s\", got)\n\t}\n\tif got := buildMessagesURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/messages\" {\n\t\tt.Fatalf(\"buildMessagesURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1#\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() with marker = %s\", got)\n\t}\n}\n\nfunc TestGetUpstreams_IncludesUnifiedStateFields(t *testing.T) {\n\tchannel := config.UpstreamConfig{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tAPIKeys: []string{\"sk-1\"},\n\t\tStatus: \"suspended\",\n\t\tDisabledAPIKeys: []config.DisabledKeyInfo{{\n\t\t\tKey: \"sk-disabled\",\n\t\t\tReason: \"insufficient_balance\",\n\t\t\tMessage: \"no balance\",\n\t\t\tDisabledAt: \"2026-04-11T00:00:00Z\",\n\t\t}},\n\t}\n\t// 该兼容开关已无手工字段:用可持久化的种子模拟\"生效值为 true\",学习结论不落盘。\n\tchannel.SetCompatSeed(config.TraitNormalizeNonstandardChatRoles, true)\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{channel})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/channels\", GetUpstreams(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/channels\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200\", w.Code)\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]interface{} `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"status\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"status = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"adminState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"adminState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"effectiveState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"effectiveState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"runtimeState\"]; got != \"disabled_keys_present\" {\n\t\tt.Fatalf(\"runtimeState = %v, want disabled_keys_present\", got)\n\t}\n\tif got := resp.Channels[0][\"normalizeNonstandardChatRoles\"]; got != true {\n\t\tt.Fatalf(\"normalizeNonstandardChatRoles = %v, want true\", got)\n\t}\n}\n\nfunc TestPingChannel_WithoutBaseURLReturnsError(t *testing.T) {\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/ping/:id\", PingChannel(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/ping/0\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK && w.Code != http.StatusBadRequest {\n\t\tt.Fatalf(\"status = %d, want 200 or 400, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif got := resp[\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n\nfunc TestPingAllChannels_WithoutBaseURLMarksChannelError(t *testing.T) {\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/ping\", PingAllChannels(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/ping\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]any `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n"}, "01e8421801c2c699d13f4ef638cda19f9af22414": {"t": 1785759333.447303, "n": 4902, "h": "39e8e1a449631330", "c": "package chat\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupChatConfigManager(t *testing.T, upstream []config.UpstreamConfig) *config.ConfigManager {\n\tt.Helper()\n\tcfg := config.Config{ChatUpstream: upstream}\n\tdata, err := json.MarshalIndent(cfg, \"\", \" \")\n\tif err != nil {\n\t\tt.Fatalf(\"序列化配置失败: %v\", err)\n\t}\n\ttmpFile := filepath.Join(t.TempDir(), \"config.json\")\n\tif err := os.WriteFile(tmpFile, data, 0644); err != nil {\n\t\tt.Fatalf(\"写入配置文件失败: %v\", err)\n\t}\n\tcm, err := config.NewConfigManager(tmpFile, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"创建配置管理器失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cm.Close() })\n\treturn cm\n}\n\nfunc TestBuildHealthCheckURLs_UseExistingVersionSuffix(t *testing.T) {\n\tif got := buildModelsURL(\"https://api.deepseek.com\"); got != \"https://api.deepseek.com/models\" {\n\t\tt.Fatalf(\"DeepSeek buildModelsURL() = %s\", got)\n\t}\n\tif got := buildMessagesURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/messages\" {\n\t\tt.Fatalf(\"buildMessagesURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1#\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() with marker = %s\", got)\n\t}\n}\n\nfunc TestGetUpstreams_IncludesUnifiedStateFields(t *testing.T) {\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tAPIKeys: []string{\"sk-1\"},\n\t\tStatus: \"suspended\",\n\t\tNormalizeNonstandardChatRoles: config.BoolPtr(true),\n\t\tDisabledAPIKeys: []config.DisabledKeyInfo{{\n\t\t\tKey: \"sk-disabled\",\n\t\t\tReason: \"insufficient_balance\",\n\t\t\tMessage: \"no balance\",\n\t\t\tDisabledAt: \"2026-04-11T00:00:00Z\",\n\t\t}},\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/channels\", GetUpstreams(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/channels\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200\", w.Code)\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]interface{} `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"status\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"status = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"adminState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"adminState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"effectiveState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"effectiveState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"runtimeState\"]; got != \"disabled_keys_present\" {\n\t\tt.Fatalf(\"runtimeState = %v, want disabled_keys_present\", got)\n\t}\n\tif got := resp.Channels[0][\"normalizeNonstandardChatRoles\"]; got != true {\n\t\tt.Fatalf(\"normalizeNonstandardChatRoles = %v, want true\", got)\n\t}\n}\n\nfunc TestPingChannel_WithoutBaseURLReturnsError(t *testing.T) {\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/ping/:id\", PingChannel(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/ping/0\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK && w.Code != http.StatusBadRequest {\n\t\tt.Fatalf(\"status = %d, want 200 or 400, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif got := resp[\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n\nfunc TestPingAllChannels_WithoutBaseURLMarksChannelError(t *testing.T) {\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/ping\", PingAllChannels(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/ping\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]any `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n"}, "6a6b2055828ba187fd85475333e90bf7166f183c": {"t": 1785759335.0939212, "n": 4886, "h": "25d83388f76e587d", "c": "package chat\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupChatConfigManager(t *testing.T, upstream []config.UpstreamConfig) *config.ConfigManager {\n\tt.Helper()\n\tcfg := config.Config{ChatUpstream: upstream}\n\tdata, err := json.MarshalIndent(cfg, \"\", \" \")\n\tif err != nil {\n\t\tt.Fatalf(\"序列化配置失败: %v\", err)\n\t}\n\ttmpFile := filepath.Join(t.TempDir(), \"config.json\")\n\tif err := os.WriteFile(tmpFile, data, 0644); err != nil {\n\t\tt.Fatalf(\"写入配置文件失败: %v\", err)\n\t}\n\tcm, err := config.NewConfigManager(tmpFile, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"创建配置管理器失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cm.Close() })\n\treturn cm\n}\n\nfunc TestBuildHealthCheckURLs_UseExistingVersionSuffix(t *testing.T) {\n\tif got := buildModelsURL(\"https://api.deepseek.com\"); got != \"https://api.deepseek.com/models\" {\n\t\tt.Fatalf(\"DeepSeek buildModelsURL() = %s\", got)\n\t}\n\tif got := buildMessagesURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/messages\" {\n\t\tt.Fatalf(\"buildMessagesURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1#\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() with marker = %s\", got)\n\t}\n}\n\nfunc TestGetUpstreams_IncludesUnifiedStateFields(t *testing.T) {\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tAPIKeys: []string{\"sk-1\"},\n\t\tStatus: \"suspended\",\n\t\tNormalizeNonstandardChatRoles: true,\n\t\tDisabledAPIKeys: []config.DisabledKeyInfo{{\n\t\t\tKey: \"sk-disabled\",\n\t\t\tReason: \"insufficient_balance\",\n\t\t\tMessage: \"no balance\",\n\t\t\tDisabledAt: \"2026-04-11T00:00:00Z\",\n\t\t}},\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/channels\", GetUpstreams(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/channels\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200\", w.Code)\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]interface{} `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"status\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"status = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"adminState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"adminState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"effectiveState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"effectiveState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"runtimeState\"]; got != \"disabled_keys_present\" {\n\t\tt.Fatalf(\"runtimeState = %v, want disabled_keys_present\", got)\n\t}\n\tif got := resp.Channels[0][\"normalizeNonstandardChatRoles\"]; got != true {\n\t\tt.Fatalf(\"normalizeNonstandardChatRoles = %v, want true\", got)\n\t}\n}\n\nfunc TestPingChannel_WithoutBaseURLReturnsError(t *testing.T) {\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/ping/:id\", PingChannel(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/ping/0\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK && w.Code != http.StatusBadRequest {\n\t\tt.Fatalf(\"status = %d, want 200 or 400, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif got := resp[\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n\nfunc TestPingAllChannels_WithoutBaseURLMarksChannelError(t *testing.T) {\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/ping\", PingAllChannels(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/ping\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]any `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n"}, "5d2ea67008b8062dd3b4f7104a16827e50c393b3": {"t": 1785759336.4791875, "n": 4882, "h": "530c961ce41d99d3", "c": "package chat\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupChatConfigManager(t *testing.T, upstream []config.UpstreamConfig) *config.ConfigManager {\n\tt.Helper()\n\tcfg := config.Config{ChatUpstream: upstream}\n\tdata, err := json.MarshalIndent(cfg, \"\", \" \")\n\tif err != nil {\n\t\tt.Fatalf(\"序列化配置失败: %v\", err)\n\t}\n\ttmpFile := filepath.Join(t.TempDir(), \"config.json\")\n\tif err := os.WriteFile(tmpFile, data, 0644); err != nil {\n\t\tt.Fatalf(\"写入配置文件失败: %v\", err)\n\t}\n\tcm, err := config.NewConfigManager(tmpFile, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"创建配置管理器失败: %v\", err)\n\t}\n\tt.Cleanup(func() { cm.Close() })\n\treturn cm\n}\n\nfunc TestBuildHealthCheckURLs_UseExistingVersionSuffix(t *testing.T) {\n\tif got := buildModelsURL(\"https://api.deepseek.com\"); got != \"https://api.deepseek.com/models\" {\n\t\tt.Fatalf(\"DeepSeek buildModelsURL() = %s\", got)\n\t}\n\tif got := buildMessagesURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/messages\" {\n\t\tt.Fatalf(\"buildMessagesURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1#\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() with marker = %s\", got)\n\t}\n}\n\nfunc TestGetUpstreams_IncludesUnifiedStateFields(t *testing.T) {\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tAPIKeys: []string{\"sk-1\"},\n\t\tStatus: \"suspended\",\n\t\tNormalizeNonstandardChatRoles: true,\n\t\tDisabledAPIKeys: []config.DisabledKeyInfo{{\n\t\t\tKey: \"sk-disabled\",\n\t\t\tReason: \"insufficient_balance\",\n\t\t\tMessage: \"no balance\",\n\t\t\tDisabledAt: \"2026-04-11T00:00:00Z\",\n\t\t}},\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/channels\", GetUpstreams(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/channels\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200\", w.Code)\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]interface{} `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"status\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"status = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"adminState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"adminState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"effectiveState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"effectiveState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"runtimeState\"]; got != \"disabled_keys_present\" {\n\t\tt.Fatalf(\"runtimeState = %v, want disabled_keys_present\", got)\n\t}\n\tif got := resp.Channels[0][\"normalizeNonstandardChatRoles\"]; got != true {\n\t\tt.Fatalf(\"normalizeNonstandardChatRoles = %v, want true\", got)\n\t}\n}\n\nfunc TestPingChannel_WithoutBaseURLReturnsError(t *testing.T) {\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/ping/:id\", PingChannel(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/ping/0\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK && w.Code != http.StatusBadRequest {\n\t\tt.Fatalf(\"status = %d, want 200 or 400, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif got := resp[\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n\nfunc TestPingAllChannels_WithoutBaseURLMarksChannelError(t *testing.T) {\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/ping\", PingAllChannels(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/ping\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]any `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n"}, "42375acbe84df03ecc7bfbf3dcd7e61e1378b8bf": {"t": 1785759337.963258, "n": 4731, "h": "40f37b7afa16b789", "c": "package chat\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupChatConfigManager(t *testing.T, upstream []config.UpstreamConfig) *config.ConfigManager {\n\tt.Helper()\n\tcfg := config.Config{ChatUpstream: upstream}\n\tdata, err := json.MarshalIndent(cfg, \"\", \" \")\n\tif err != nil {\n\t\tt.Fatalf(\"序列化配置失败: %v\", err)\n\t}\n\ttmpFile := filepath.Join(t.TempDir(), \"config.json\")\n\tif err := os.WriteFile(tmpFile, data, 0644); err != nil {\n\t\tt.Fatalf(\"写入配置文件失败: %v\", err)\n\t}\n\tcm, err := config.NewConfigManager(tmpFile, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"创建配置管理器失败: %v\", err)\n\t}\n\tt.Cleanup(func() { cm.Close() })\n\treturn cm\n}\n\nfunc TestBuildHealthCheckURLs_UseExistingVersionSuffix(t *testing.T) {\n\tif got := buildMessagesURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/messages\" {\n\t\tt.Fatalf(\"buildMessagesURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1#\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() with marker = %s\", got)\n\t}\n}\n\nfunc TestGetUpstreams_IncludesUnifiedStateFields(t *testing.T) {\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tAPIKeys: []string{\"sk-1\"},\n\t\tStatus: \"suspended\",\n\t\tNormalizeNonstandardChatRoles: true,\n\t\tDisabledAPIKeys: []config.DisabledKeyInfo{{\n\t\t\tKey: \"sk-disabled\",\n\t\t\tReason: \"insufficient_balance\",\n\t\t\tMessage: \"no balance\",\n\t\t\tDisabledAt: \"2026-04-11T00:00:00Z\",\n\t\t}},\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/channels\", GetUpstreams(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/channels\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200\", w.Code)\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]interface{} `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"status\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"status = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"adminState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"adminState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"effectiveState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"effectiveState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"runtimeState\"]; got != \"disabled_keys_present\" {\n\t\tt.Fatalf(\"runtimeState = %v, want disabled_keys_present\", got)\n\t}\n\tif got := resp.Channels[0][\"normalizeNonstandardChatRoles\"]; got != true {\n\t\tt.Fatalf(\"normalizeNonstandardChatRoles = %v, want true\", got)\n\t}\n}\n\nfunc TestPingChannel_WithoutBaseURLReturnsError(t *testing.T) {\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/ping/:id\", PingChannel(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/ping/0\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK && w.Code != http.StatusBadRequest {\n\t\tt.Fatalf(\"status = %d, want 200 or 400, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif got := resp[\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n\nfunc TestPingAllChannels_WithoutBaseURLMarksChannelError(t *testing.T) {\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/ping\", PingAllChannels(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/ping\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]any `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n"}, "75bc9b260a8972af8da17b8d77b414d168fc719a": {"t": 1785759339.216025, "n": 4727, "h": "123bf8a4abe4ef63", "c": "package chat\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupChatConfigManager(t *testing.T, upstream []config.UpstreamConfig) *config.ConfigManager {\n\tt.Helper()\n\tcfg := config.Config{ChatUpstream: upstream}\n\tdata, err := json.MarshalIndent(cfg, \"\", \" \")\n\tif err != nil {\n\t\tt.Fatalf(\"序列化配置失败: %v\", err)\n\t}\n\ttmpFile := filepath.Join(t.TempDir(), \"config.json\")\n\tif err := os.WriteFile(tmpFile, data, 0644); err != nil {\n\t\tt.Fatalf(\"写入配置文件失败: %v\", err)\n\t}\n\tcm, err := config.NewConfigManager(tmpFile)\n\tif err != nil {\n\t\tt.Fatalf(\"创建配置管理器失败: %v\", err)\n\t}\n\tt.Cleanup(func() { cm.Close() })\n\treturn cm\n}\n\nfunc TestBuildHealthCheckURLs_UseExistingVersionSuffix(t *testing.T) {\n\tif got := buildMessagesURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/messages\" {\n\t\tt.Fatalf(\"buildMessagesURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1#\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() with marker = %s\", got)\n\t}\n}\n\nfunc TestGetUpstreams_IncludesUnifiedStateFields(t *testing.T) {\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tAPIKeys: []string{\"sk-1\"},\n\t\tStatus: \"suspended\",\n\t\tNormalizeNonstandardChatRoles: true,\n\t\tDisabledAPIKeys: []config.DisabledKeyInfo{{\n\t\t\tKey: \"sk-disabled\",\n\t\t\tReason: \"insufficient_balance\",\n\t\t\tMessage: \"no balance\",\n\t\t\tDisabledAt: \"2026-04-11T00:00:00Z\",\n\t\t}},\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/channels\", GetUpstreams(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/channels\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200\", w.Code)\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]interface{} `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"status\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"status = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"adminState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"adminState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"effectiveState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"effectiveState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"runtimeState\"]; got != \"disabled_keys_present\" {\n\t\tt.Fatalf(\"runtimeState = %v, want disabled_keys_present\", got)\n\t}\n\tif got := resp.Channels[0][\"normalizeNonstandardChatRoles\"]; got != true {\n\t\tt.Fatalf(\"normalizeNonstandardChatRoles = %v, want true\", got)\n\t}\n}\n\nfunc TestPingChannel_WithoutBaseURLReturnsError(t *testing.T) {\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/ping/:id\", PingChannel(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/ping/0\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK && w.Code != http.StatusBadRequest {\n\t\tt.Fatalf(\"status = %d, want 200 or 400, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif got := resp[\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n\nfunc TestPingAllChannels_WithoutBaseURLMarksChannelError(t *testing.T) {\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/ping\", PingAllChannels(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/ping\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]any `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n"}, "8b9bc6b280fb9b0dd8348e808d46d3a1606c9cd5": {"t": 1785759340.783037, "n": 4454, "h": "5e603ee9ed0b2f3b", "c": "package chat\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupChatConfigManager(t *testing.T, upstream []config.UpstreamConfig) *config.ConfigManager {\n\tt.Helper()\n\tcfg := config.Config{ChatUpstream: upstream}\n\tdata, err := json.MarshalIndent(cfg, \"\", \" \")\n\tif err != nil {\n\t\tt.Fatalf(\"序列化配置失败: %v\", err)\n\t}\n\ttmpFile := filepath.Join(t.TempDir(), \"config.json\")\n\tif err := os.WriteFile(tmpFile, data, 0644); err != nil {\n\t\tt.Fatalf(\"写入配置文件失败: %v\", err)\n\t}\n\tcm, err := config.NewConfigManager(tmpFile)\n\tif err != nil {\n\t\tt.Fatalf(\"创建配置管理器失败: %v\", err)\n\t}\n\tt.Cleanup(func() { cm.Close() })\n\treturn cm\n}\n\nfunc TestBuildHealthCheckURLs_UseExistingVersionSuffix(t *testing.T) {\n\tif got := buildMessagesURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/messages\" {\n\t\tt.Fatalf(\"buildMessagesURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1#\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() with marker = %s\", got)\n\t}\n}\n\nfunc TestGetUpstreams_IncludesUnifiedStateFields(t *testing.T) {\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tAPIKeys: []string{\"sk-1\"},\n\t\tStatus: \"suspended\",\n\t\tDisabledAPIKeys: []config.DisabledKeyInfo{{\n\t\t\tKey: \"sk-disabled\",\n\t\t\tReason: \"insufficient_balance\",\n\t\t\tMessage: \"no balance\",\n\t\t\tDisabledAt: \"2026-04-11T00:00:00Z\",\n\t\t}},\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/channels\", GetUpstreams(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/channels\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200\", w.Code)\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]interface{} `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"status\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"status = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"adminState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"adminState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"effectiveState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"effectiveState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"runtimeState\"]; got != \"disabled_keys_present\" {\n\t\tt.Fatalf(\"runtimeState = %v, want disabled_keys_present\", got)\n\t}\n}\n\nfunc TestPingChannel_WithoutBaseURLReturnsError(t *testing.T) {\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/ping/:id\", PingChannel(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/ping/0\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK && w.Code != http.StatusBadRequest {\n\t\tt.Fatalf(\"status = %d, want 200 or 400, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif got := resp[\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n\nfunc TestPingAllChannels_WithoutBaseURLMarksChannelError(t *testing.T) {\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/ping\", PingAllChannels(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/ping\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]any `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n"}, "3e4861fce509b5a2a69c40894daf4371f91ea276": {"t": 1785759343.741178, "n": 2610, "h": "a0f25c3a1b84ffec", "c": "package chat\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupChatConfigManager(t *testing.T, upstream []config.UpstreamConfig) *config.ConfigManager {\n\tt.Helper()\n\tcfg := config.Config{ChatUpstream: upstream}\n\tdata, err := json.MarshalIndent(cfg, \"\", \" \")\n\tif err != nil {\n\t\tt.Fatalf(\"序列化配置失败: %v\", err)\n\t}\n\ttmpFile := filepath.Join(t.TempDir(), \"config.json\")\n\tif err := os.WriteFile(tmpFile, data, 0644); err != nil {\n\t\tt.Fatalf(\"写入配置文件失败: %v\", err)\n\t}\n\tcm, err := config.NewConfigManager(tmpFile)\n\tif err != nil {\n\t\tt.Fatalf(\"创建配置管理器失败: %v\", err)\n\t}\n\tt.Cleanup(func() { cm.Close() })\n\treturn cm\n}\n\nfunc TestBuildHealthCheckURLs_UseExistingVersionSuffix(t *testing.T) {\n\tif got := buildMessagesURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/messages\" {\n\t\tt.Fatalf(\"buildMessagesURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1#\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() with marker = %s\", got)\n\t}\n}\n\nfunc TestGetUpstreams_IncludesAdvancedOptionFields(t *testing.T) {\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tAPIKeys: []string{\"sk-1\"},\n\t\tModelMapping: map[string]string{\"gpt-5\": \"gpt-5.2\"},\n\t\tReasoningMapping: map[string]string{\"gpt-5\": \"xhigh\"},\n\t\tTextVerbosity: \"low\",\n\t\tFastMode: true,\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/channels\", GetUpstreams(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/channels\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200\", w.Code)\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]interface{} `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tch := resp.Channels[0]\n\tif ch[\"textVerbosity\"] != \"low\" {\n\t\tt.Fatalf(\"textVerbosity = %v, want low\", ch[\"textVerbosity\"])\n\t}\n\tif ch[\"fastMode\"] != true {\n\t\tt.Fatalf(\"fastMode = %v, want true\", ch[\"fastMode\"])\n\t}\n\trm, ok := ch[\"reasoningMapping\"].(map[string]interface{})\n\tif !ok || rm[\"gpt-5\"] != \"xhigh\" {\n\t\tt.Fatalf(\"reasoningMapping = %#v, want gpt-5=xhigh\", ch[\"reasoningMapping\"])\n\t}\n}\n"}, "f7804d683cacbc7284a4e0bda1ecd12991994c84": {"t": 1785759345.6994405, "n": 2043, "h": "1eb687e89eafa1fb", "c": "package chat\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupChatConfigManager(t *testing.T, upstream []config.UpstreamConfig) *config.ConfigManager {\n\tt.Helper()\n\tcfg := config.Config{ChatUpstream: upstream}\n\tdata, err := json.MarshalIndent(cfg, \"\", \" \")\n\tif err != nil {\n\t\tt.Fatalf(\"序列化配置失败: %v\", err)\n\t}\n\ttmpFile := filepath.Join(t.TempDir(), \"config.json\")\n\tif err := os.WriteFile(tmpFile, data, 0644); err != nil {\n\t\tt.Fatalf(\"写入配置文件失败: %v\", err)\n\t}\n\tcm, err := config.NewConfigManager(tmpFile)\n\tif err != nil {\n\t\tt.Fatalf(\"创建配置管理器失败: %v\", err)\n\t}\n\tt.Cleanup(func() { cm.Close() })\n\treturn cm\n}\n\nfunc TestGetUpstreams_IncludesAdvancedOptionFields(t *testing.T) {\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tAPIKeys: []string{\"sk-1\"},\n\t\tModelMapping: map[string]string{\"gpt-5\": \"gpt-5.2\"},\n\t\tReasoningMapping: map[string]string{\"gpt-5\": \"xhigh\"},\n\t\tTextVerbosity: \"low\",\n\t\tFastMode: true,\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/channels\", GetUpstreams(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/channels\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200\", w.Code)\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]interface{} `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tch := resp.Channels[0]\n\tif ch[\"textVerbosity\"] != \"low\" {\n\t\tt.Fatalf(\"textVerbosity = %v, want low\", ch[\"textVerbosity\"])\n\t}\n\tif ch[\"fastMode\"] != true {\n\t\tt.Fatalf(\"fastMode = %v, want true\", ch[\"fastMode\"])\n\t}\n\trm, ok := ch[\"reasoningMapping\"].(map[string]interface{})\n\tif !ok || rm[\"gpt-5\"] != \"xhigh\" {\n\t\tt.Fatalf(\"reasoningMapping = %#v, want gpt-5=xhigh\", ch[\"reasoningMapping\"])\n\t}\n}\n"}, "71b842e3e8300d0b9329446af03a39c498580b55": {"t": 1786068175.2151082, "n": 4917, "h": "2b811f88fc288c8c", "c": "package chat\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupChatConfigManager(t *testing.T, upstream []config.UpstreamConfig) *config.ConfigManager {\n\tt.Helper()\n\tcfg := config.Config{ChatUpstream: upstream}\n\tdata, err := json.MarshalIndent(cfg, \"\", \" \")\n\tif err != nil {\n\t\tt.Fatalf(\"序列化配置失败: %v\", err)\n\t}\n\ttmpFile := filepath.Join(t.TempDir(), \"config.json\")\n\tif err := os.WriteFile(tmpFile, data, 0644); err != nil {\n\t\tt.Fatalf(\"写入配置文件失败: %v\", err)\n\t}\n\tcm, err := config.NewConfigManager(tmpFile, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"创建配置管理器失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cm.Close() })\n\treturn cm\n}\n\nfunc TestBuildHealthCheckURLs_UseExistingVersionSuffix(t *testing.T) {\n\tif got := buildModelsURL(\"https://api.deepseek.com\"); got != \"https://api.deepseek.com/models\" {\n\t\tt.Fatalf(\"DeepSeek buildModelsURL() = %s\", got)\n\t}\n\tif got := buildMessagesURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/messages\" {\n\t\tt.Fatalf(\"buildMessagesURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1#\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() with marker = %s\", got)\n\t}\n}\n\nfunc TestGetUpstreams_IncludesUnifiedStateFields(t *testing.T) {\n\tchannel := config.UpstreamConfig{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tAPIKeys: []string{\"sk-1\"},\n\t\tStatus: \"suspended\",\n\t\tDisabledAPIKeys: []config.DisabledKeyInfo{{\n\t\t\tKey: \"sk-disabled\",\n\t\t\tReason: \"insufficient_balance\",\n\t\t\tMessage: \"no balance\",\n\t\t\tDisabledAt: \"2026-04-11T00:00:00Z\",\n\t\t}},\n\t}\n\t// 该兼容开关已无手工字段:用可持久化的种子模拟\"生效值为 true\",学习结论不落盘。\n\tchannel.SetCompatSeed(config.TraitNormalizeNonstandardChatRoles, true)\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{channel})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/channels\", GetUpstreams(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/channels\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200\", w.Code)\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]interface{} `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"status\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"status = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"adminState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"adminState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"effectiveState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"effectiveState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"runtimeState\"]; got != \"disabled_keys_present\" {\n\t\tt.Fatalf(\"runtimeState = %v, want disabled_keys_present\", got)\n\t}\n\tif got := resp.Channels[0][\"normalizeNonstandardChatRoles\"]; got != true {\n\t\tt.Fatalf(\"normalizeNonstandardChatRoles = %v, want true\", got)\n\t}\n}\n\nfunc TestPingChannel_WithoutBaseURLReturnsError(t *testing.T) {\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/ping/:id\", PingChannel(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/ping/0\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK && w.Code != http.StatusBadRequest {\n\t\tt.Fatalf(\"status = %d, want 200 or 400, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif got := resp[\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n\nfunc TestPingAllChannels_WithoutBaseURLMarksChannelError(t *testing.T) {\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/ping\", PingAllChannels(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/ping\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]any `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n"}, "8cb1f08da90e4858885f246ddadd488390fdba08": {"t": 1786068620.944626, "n": 4917, "h": "2b811f88fc288c8c", "c": "package chat\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/ccx/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc setupChatConfigManager(t *testing.T, upstream []config.UpstreamConfig) *config.ConfigManager {\n\tt.Helper()\n\tcfg := config.Config{ChatUpstream: upstream}\n\tdata, err := json.MarshalIndent(cfg, \"\", \" \")\n\tif err != nil {\n\t\tt.Fatalf(\"序列化配置失败: %v\", err)\n\t}\n\ttmpFile := filepath.Join(t.TempDir(), \"config.json\")\n\tif err := os.WriteFile(tmpFile, data, 0644); err != nil {\n\t\tt.Fatalf(\"写入配置文件失败: %v\", err)\n\t}\n\tcm, err := config.NewConfigManager(tmpFile, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"创建配置管理器失败: %v\", err)\n\t}\n\tt.Cleanup(func() { _ = cm.Close() })\n\treturn cm\n}\n\nfunc TestBuildHealthCheckURLs_UseExistingVersionSuffix(t *testing.T) {\n\tif got := buildModelsURL(\"https://api.deepseek.com\"); got != \"https://api.deepseek.com/models\" {\n\t\tt.Fatalf(\"DeepSeek buildModelsURL() = %s\", got)\n\t}\n\tif got := buildMessagesURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/messages\" {\n\t\tt.Fatalf(\"buildMessagesURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() = %s\", got)\n\t}\n\tif got := buildModelsURL(\"https://api.example.com/codex/v1#\"); got != \"https://api.example.com/codex/v1/models\" {\n\t\tt.Fatalf(\"buildModelsURL() with marker = %s\", got)\n\t}\n}\n\nfunc TestGetUpstreams_IncludesUnifiedStateFields(t *testing.T) {\n\tchannel := config.UpstreamConfig{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t\tBaseURL: \"https://api.example.com\",\n\t\tAPIKeys: []string{\"sk-1\"},\n\t\tStatus: \"suspended\",\n\t\tDisabledAPIKeys: []config.DisabledKeyInfo{{\n\t\t\tKey: \"sk-disabled\",\n\t\t\tReason: \"insufficient_balance\",\n\t\t\tMessage: \"no balance\",\n\t\t\tDisabledAt: \"2026-04-11T00:00:00Z\",\n\t\t}},\n\t}\n\t// 该兼容开关已无手工字段:用可持久化的种子模拟\"生效值为 true\",学习结论不落盘。\n\tchannel.SetCompatSeed(config.TraitNormalizeNonstandardChatRoles, true)\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{channel})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/channels\", GetUpstreams(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/channels\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200\", w.Code)\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]interface{} `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"status\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"status = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"adminState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"adminState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"effectiveState\"]; got != \"suspended\" {\n\t\tt.Fatalf(\"effectiveState = %v, want suspended\", got)\n\t}\n\tif got := resp.Channels[0][\"runtimeState\"]; got != \"disabled_keys_present\" {\n\t\tt.Fatalf(\"runtimeState = %v, want disabled_keys_present\", got)\n\t}\n\tif got := resp.Channels[0][\"normalizeNonstandardChatRoles\"]; got != true {\n\t\tt.Fatalf(\"normalizeNonstandardChatRoles = %v, want true\", got)\n\t}\n}\n\nfunc TestPingChannel_WithoutBaseURLReturnsError(t *testing.T) {\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/ping/:id\", PingChannel(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/ping/0\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK && w.Code != http.StatusBadRequest {\n\t\tt.Fatalf(\"status = %d, want 200 or 400, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp map[string]any\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif got := resp[\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n\nfunc TestPingAllChannels_WithoutBaseURLMarksChannelError(t *testing.T) {\n\tcm := setupChatConfigManager(t, []config.UpstreamConfig{{\n\t\tName: \"chat-ch\",\n\t\tServiceType: \"openai\",\n\t}})\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.GET(\"/chat/ping\", PingAllChannels(cm))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/chat/ping\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want 200, body=%s\", w.Code, w.Body.String())\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]any `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"len(channels) = %d, want 1\", len(resp.Channels))\n\t}\n\tif got := resp.Channels[0][\"error\"]; got == nil {\n\t\tt.Fatalf(\"error = %v, want non-nil\", got)\n\t}\n}\n"}}, "desktop/internal/configservice/service.go": {"784041ebc2a680321b17c44db2f89e95b9470dbe": {"t": 1785809839.4097173, "n": 11141, "h": "16e7724a667b5f6e", "c": "package configservice\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/BenedictKing/ccx/desktop/internal/appdirs\"\n)\n\nconst (\n\tPlatformClaude = \"claude\"\n\tPlatformCodex = \"codex\"\n\tPlatformOpenCode = \"opencode\"\n\n\tProviderCCX = \"ccx\"\n\tProviderDeepSeek = \"deepseek\"\n\tProviderMiMo = \"mimo\"\n\tProviderCompshare = \"compshare\"\n\tProviderRunAPI = \"runapi\"\n\tProviderUnity2 = \"unity2\"\n\tProviderKimi = \"kimi\"\n\tProviderGLM = \"glm\"\n\tProviderSenseNova = \"sensenova\"\n\tProviderMiniMax = \"minimax\"\n\tProviderDashScope = \"dashscope\"\n\tProviderOpenCodeZen = \"opencode-zen\"\n\tProviderOpenCodeGo = \"opencode-go\"\n\tProviderCustom = \"custom\"\n\tProviderOpenAI = \"openai\"\n\tProviderXFyun = \"xfyun\"\n\tProviderTencentLkeap = \"tencent-lkeap\"\n\tProviderVolcArk = \"volc-ark\"\n\tProviderQianfan = \"qianfan\"\n\tProviderModelScope = \"modelscope\"\n\n\tdeepSeekClaudeBaseURL = \"https://api.deepseek.com/anthropic\"\n\tdefaultMiMoBaseURL = \"https://api.xiaomimimo.com/anthropic\"\n\tcompshareClaudeBaseURL = \"https://cp.compshare.cn\"\n\trunAPIBaseURL = \"https://runapi.co/v1\"\n\tunity2BaseURL = \"https://unity2.ai/v1\"\n\tkimiClaudeBaseURL = \"https://api.moonshot.cn/anthropic\"\n\tglmClaudeBaseURL = \"https://open.bigmodel.cn/api/anthropic\"\n\tsensenovaClaudeBaseURL = \"https://token.sensenova.cn\"\n\tsensenovaChatBaseURL = \"https://token.sensenova.cn/v1\"\n\tminiMaxClaudeBaseURL = \"https://api.minimaxi.com/anthropic\"\n\tdashScopeClaudeBaseURL = \"https://dashscope.aliyuncs.com/apps/anthropic\"\n\tdashScopeCodingPlanClaudeBaseURL = \"https://coding.dashscope.aliyuncs.com/apps/anthropic\"\n\topenCodeZenClaudeBaseURL = \"https://opencode.ai/zen\"\n\topenCodeGoClaudeBaseURL = \"https://opencode.ai/zen/go\"\n\txfyunClaudeBaseURL = \"https://maas-coding-api.cn-huabei-1.xf-yun.com/anthropic\"\n\txfyunCodexBaseURL = \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v2\"\n\ttencentLkeapClaudeBaseURL = \"https://api.lkeap.cloud.tencent.com/plan/anthropic\"\n\tvolcArkClaudeBaseURL = \"https://ark.cn-beijing.volces.com/api/coding\"\n\tqianfanClaudeBaseURL = \"https://qianfan.baidubce.com/anthropic/coding\"\n\ttencentLkeapCodexBaseURL = \"https://api.lkeap.cloud.tencent.com/plan/v3\"\n\tvolcArkCodexBaseURL = \"https://ark.cn-beijing.volces.com/api/coding/v3\"\n\tqianfanCodexBaseURL = \"https://qianfan.baidubce.com/v2/coding\"\n\tmodelScopeCodexBaseURL = \"https://api-inference.modelscope.cn/v1\"\n\tstateVersion = 1\n\n\topenCodeZenBaseURL = \"https://opencode.ai/zen/v1\"\n\topenCodeGoBaseURL = \"https://opencode.ai/zen/go/v1\"\n)\n\ntype AgentConfigStatus struct {\n\tPlatform string `json:\"platform\"`\n\tProvider string `json:\"provider,omitempty\"`\n\tMode string `json:\"mode,omitempty\"` // \"quick\" | \"plugin\"\n\tTargetProvider string `json:\"targetProvider,omitempty\"`\n\tConfigured bool `json:\"configured\"`\n\tMatchesCurrentPort bool `json:\"matchesCurrentPort\"`\n\tNeedsUpdate bool `json:\"needsUpdate\"`\n\tCurrentBaseURL string `json:\"currentBaseUrl\"`\n\tTargetBaseURL string `json:\"targetBaseUrl\"`\n\tConfigPath string `json:\"configPath\"`\n\tAuthPath string `json:\"authPath,omitempty\"`\n\tHasState bool `json:\"hasState\"`\n\tLastError string `json:\"lastError,omitempty\"`\n\n\t// Codex 专属:config.toml 与 auth.json 一致性诊断。\n\t// 用于识别 CCS 等工具或手工编辑导致的配置污染,避免用户只看到上游 503。\n\tAuthMode string `json:\"authMode,omitempty\"` // auth.json 的 auth_mode:\"apikey\" | \"chatgpt\"\n\tConfigConsistent bool `json:\"configConsistent\"` // config.toml 与 auth.json 语义是否一致\n\tDiagnosticCode string `json:\"diagnosticCode,omitempty\"` // 不一致时的诊断码,如 codex.missing_api_key\n\tDiagnosticMessage string `json:\"diagnosticMessage,omitempty\"` // 面向用户的诊断说明\n}\n\ntype ApplyAgentConfigRequest struct {\n\tPlatform string `json:\"platform\"`\n\tProvider string `json:\"provider,omitempty\"`\n\tAPIKey string `json:\"apiKey,omitempty\"`\n\tBaseURL string `json:\"baseUrl,omitempty\"`\n\tMode string `json:\"mode,omitempty\"` // \"quick\" | \"plugin\"\n}\n\ntype MigrateCodexSessionsRequest struct {\n\tProvider string `json:\"provider,omitempty\"`\n\tMode string `json:\"mode,omitempty\"` // \"quick\" | \"plugin\"\n}\n\ntype MigrateCodexSessionsResult struct {\n\tTargetProvider string `json:\"targetProvider\"`\n\tTotalFiles int `json:\"totalFiles\"`\n\tMigratedFiles int `json:\"migratedFiles\"`\n\tSkippedFiles int `json:\"skippedFiles\"`\n\tFailedFiles int `json:\"failedFiles\"`\n\tSQLiteRowsUpdated int64 `json:\"sqliteRowsUpdated\"`\n\tSQLiteSkipped bool `json:\"sqliteSkipped\"`\n\tSQLiteError string `json:\"sqliteError,omitempty\"`\n}\n\ntype Service struct {\n\thomeDir string\n\tstateDir string\n}\n\ntype codexThreadsColumns struct {\n\tmodelProvider bool\n\tpreview bool\n\tfirstUserMessage bool\n\thasUserEvent bool\n\tthreadSource bool\n\tsource bool\n}\n\ntype ClaudeProxyState struct {\n\tVersion int `json:\"version\"`\n\tTargetPath string `json:\"targetPath\"`\n\tFileExisted bool `json:\"fileExisted\"`\n\tEnvExisted bool `json:\"envExisted\"`\n\tOriginalBaseURL *string `json:\"originalBaseUrl,omitempty\"`\n\tOriginalAuthToken *string `json:\"originalAuthToken,omitempty\"`\n\tOriginalAPIKey *string `json:\"originalApiKey,omitempty\"`\n\tOriginalModel *string `json:\"originalModel,omitempty\"`\n\tOriginalSmallFast *string `json:\"originalSmallFast,omitempty\"`\n\tInjectedProvider string `json:\"injectedProvider\"`\n\tInjectedBaseURL string `json:\"injectedBaseUrl\"`\n\tInjectedAuthToken string `json:\"injectedAuthToken,omitempty\"`\n\tInjectedAPIKey string `json:\"injectedApiKey,omitempty\"`\n}\n\ntype CodexProxyState struct {\n\tVersion int `json:\"version\"`\n\tConfigPath string `json:\"configPath\"`\n\tAuthPath string `json:\"authPath\"`\n\tConfigFileExisted bool `json:\"configFileExisted\"`\n\tAuthFileExisted bool `json:\"authFileExisted\"`\n\tOriginalModelProvider *string `json:\"originalModelProvider,omitempty\"`\n\tOriginalProviderBlock *string `json:\"originalProviderBlock,omitempty\"`\n\tOriginalOpenAIAPIKey *string `json:\"originalOpenaiApiKey,omitempty\"`\n\tOriginalOpenAIBaseURL *string `json:\"originalOpenaiBaseUrl,omitempty\"`\n\tInjectedProvider string `json:\"injectedProvider,omitempty\"`\n\tInjectedBaseURL string `json:\"injectedBaseUrl\"`\n\tInjectedAPIKey string `json:\"injectedApiKey\"`\n\tThirdPartyQuickMode bool `json:\"thirdPartyQuickMode,omitempty\"`\n}\n\ntype DiffLine struct {\n\tType string `json:\"type\"` // \"context\" | \"added\" | \"removed\"\n\tContent string `json:\"content\"`\n}\n\ntype FileDiff struct {\n\tPath string `json:\"path\"`\n\tAction string `json:\"action\"` // \"modify\" | \"create\" | \"delete\"\n\tLines []DiffLine `json:\"lines\"`\n}\n\ntype ConfigDiffResult struct {\n\tFiles []FileDiff `json:\"files\"`\n}\n\ntype ProviderKeyStore struct {\n\tVersion int `json:\"version\"`\n\tKeys map[string]string `json:\"keys\"`\n\tAssets map[string]ProviderKeyAsset `json:\"assets,omitempty\"`\n}\n\ntype ProviderKeyAsset struct {\n\tProvider string `json:\"provider\"`\n\tAPIKey string `json:\"apiKey\"`\n\tBaseURL string `json:\"baseUrl,omitempty\"`\n\tPlanID string `json:\"planId,omitempty\"`\n\tProxyURL string `json:\"proxyUrl,omitempty\"`\n\tUsages []string `json:\"usages,omitempty\"`\n}\n\nfunc New(dataDir string) (*Service, error) {\n\thomeDir, err := os.UserHomeDir()\n\tif err != nil || homeDir == \"\" {\n\t\treturn nil, fmt.Errorf(\"无法定位用户主目录\")\n\t}\n\tif dataDir == \"\" {\n\t\tdataDir = appdirs.DataDirForHome(homeDir)\n\t}\n\tstateDir := filepath.Join(dataDir, \"agent-config-state\")\n\tif err := os.MkdirAll(stateDir, 0o755); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &Service{homeDir: homeDir, stateDir: stateDir}, nil\n}\n\nfunc (s *Service) GetStatus(platform string, port int) (AgentConfigStatus, error) {\n\tswitch platform {\n\tcase PlatformClaude:\n\t\treturn s.getClaudeStatus(port)\n\tcase PlatformCodex:\n\t\treturn s.getCodexStatus(port)\n\tcase PlatformOpenCode:\n\t\treturn s.getOpenCodeStatus(port)\n\tdefault:\n\t\treturn AgentConfigStatus{}, fmt.Errorf(\"不支持的 agent 平台: %s\", platform)\n\t}\n}\n\nfunc (s *Service) Apply(req ApplyAgentConfigRequest, port int, accessKey string) error {\n\tplatform := strings.TrimSpace(req.Platform)\n\tif platform == \"\" {\n\t\treturn fmt.Errorf(\"agent 平台不能为空\")\n\t}\n\tswitch platform {\n\tcase PlatformClaude:\n\t\treturn s.applyClaude(req, port, accessKey)\n\tcase PlatformCodex:\n\t\tprovider := strings.TrimSpace(req.Provider)\n\t\tif provider == ProviderOpenAI {\n\t\t\treturn s.applyCodexOpenAI(req.APIKey)\n\t\t}\n\t\tif responsesURL, ok := codexResponsesBaseURL(provider); ok {\n\t\t\tif baseURL := strings.TrimSpace(req.BaseURL); baseURL != \"\" {\n\t\t\t\tresponsesURL = baseURL\n\t\t\t}\n\t\t\tif req.Mode == \"quick\" {\n\t\t\t\treturn s.applyCodexThirdPartyQuick(provider, responsesURL, req.APIKey)\n\t\t\t}\n\t\t\treturn s.applyCodexThirdParty(provider, responsesURL, req.APIKey)\n\t\t}\n\t\tif port == 0 {\n\t\t\treturn fmt.Errorf(\"CCX 端口未设置\")\n\t\t}\n\t\tif accessKey == \"\" {\n\t\t\treturn fmt.Errorf(\"PROXY_ACCESS_KEY 为空\")\n\t\t}\n\t\treturn s.applyCodex(port, accessKey, req.Mode)\n\tcase PlatformOpenCode:\n\t\treturn s.applyOpenCode(req, port, accessKey)\n\tdefault:\n\t\treturn fmt.Errorf(\"不支持的 agent 平台: %s\", platform)\n\t}\n}\n\nfunc (s *Service) Restore(platform string) error {\n\tswitch platform {\n\tcase PlatformClaude:\n\t\treturn s.restoreClaude()\n\tcase PlatformCodex:\n\t\treturn s.restoreCodex()\n\tcase PlatformOpenCode:\n\t\treturn s.restoreOpenCode()\n\tdefault:\n\t\treturn fmt.Errorf(\"不支持的 agent 平台: %s\", platform)\n\t}\n}\n\nfunc (s *Service) PreviewApply(req ApplyAgentConfigRequest, port int, accessKey string) (ConfigDiffResult, error) {\n\tplatform := strings.TrimSpace(req.Platform)\n\tif platform == \"\" {\n\t\treturn ConfigDiffResult{}, fmt.Errorf(\"agent 平台不能为空\")\n\t}\n\tswitch platform {\n\tcase PlatformClaude:\n\t\treturn s.previewApplyClaude(req, port, accessKey)\n\tcase PlatformCodex:\n\t\tprovider := strings.TrimSpace(req.Provider)\n\t\tif provider == ProviderOpenAI {\n\t\t\treturn s.previewApplyCodexOpenAI(req.APIKey)\n\t\t}\n\t\tif responsesURL, ok := codexResponsesBaseURL(provider); ok {\n\t\t\tif baseURL := strings.TrimSpace(req.BaseURL); baseURL != \"\" {\n\t\t\t\tresponsesURL = baseURL\n\t\t\t}\n\t\t\tif req.Mode == \"quick\" {\n\t\t\t\treturn s.previewApplyCodexThirdPartyQuick(provider, responsesURL, req.APIKey)\n\t\t\t}\n\t\t\treturn s.previewApplyCodexThirdParty(provider, responsesURL, req.APIKey)\n\t\t}\n\t\treturn s.previewApplyCodex(port, accessKey, req.Mode)\n\tcase PlatformOpenCode:\n\t\treturn s.previewApplyOpenCode(req, port, accessKey)\n\tdefault:\n\t\treturn ConfigDiffResult{}, fmt.Errorf(\"不支持的 agent 平台: %s\", platform)\n\t}\n}\n\n// PreviewRestore 预览 Restore 操作的变更,不实际写入文件。\n\nfunc (s *Service) PreviewRestore(platform string) (ConfigDiffResult, error) {\n\tswitch platform {\n\tcase PlatformClaude:\n\t\treturn s.previewRestoreClaude()\n\tcase PlatformCodex:\n\t\treturn s.previewRestoreCodex()\n\tcase PlatformOpenCode:\n\t\treturn s.previewRestoreOpenCode()\n\tdefault:\n\t\treturn ConfigDiffResult{}, fmt.Errorf(\"不支持的 agent 平台: %s\", platform)\n\t}\n}\n"}, "e972d82e6b4b3edf23f948edbd0db2d4c80db655": {"t": 1785809885.0142052, "n": 11141, "h": "16e7724a667b5f6e", "c": "package configservice\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/BenedictKing/ccx/desktop/internal/appdirs\"\n)\n\nconst (\n\tPlatformClaude = \"claude\"\n\tPlatformCodex = \"codex\"\n\tPlatformOpenCode = \"opencode\"\n\n\tProviderCCX = \"ccx\"\n\tProviderDeepSeek = \"deepseek\"\n\tProviderMiMo = \"mimo\"\n\tProviderCompshare = \"compshare\"\n\tProviderRunAPI = \"runapi\"\n\tProviderUnity2 = \"unity2\"\n\tProviderKimi = \"kimi\"\n\tProviderGLM = \"glm\"\n\tProviderSenseNova = \"sensenova\"\n\tProviderMiniMax = \"minimax\"\n\tProviderDashScope = \"dashscope\"\n\tProviderOpenCodeZen = \"opencode-zen\"\n\tProviderOpenCodeGo = \"opencode-go\"\n\tProviderCustom = \"custom\"\n\tProviderOpenAI = \"openai\"\n\tProviderXFyun = \"xfyun\"\n\tProviderTencentLkeap = \"tencent-lkeap\"\n\tProviderVolcArk = \"volc-ark\"\n\tProviderQianfan = \"qianfan\"\n\tProviderModelScope = \"modelscope\"\n\n\tdeepSeekClaudeBaseURL = \"https://api.deepseek.com/anthropic\"\n\tdefaultMiMoBaseURL = \"https://api.xiaomimimo.com/anthropic\"\n\tcompshareClaudeBaseURL = \"https://cp.compshare.cn\"\n\trunAPIBaseURL = \"https://runapi.co/v1\"\n\tunity2BaseURL = \"https://unity2.ai/v1\"\n\tkimiClaudeBaseURL = \"https://api.moonshot.cn/anthropic\"\n\tglmClaudeBaseURL = \"https://open.bigmodel.cn/api/anthropic\"\n\tsensenovaClaudeBaseURL = \"https://token.sensenova.cn\"\n\tsensenovaChatBaseURL = \"https://token.sensenova.cn/v1\"\n\tminiMaxClaudeBaseURL = \"https://api.minimaxi.com/anthropic\"\n\tdashScopeClaudeBaseURL = \"https://dashscope.aliyuncs.com/apps/anthropic\"\n\tdashScopeCodingPlanClaudeBaseURL = \"https://coding.dashscope.aliyuncs.com/apps/anthropic\"\n\topenCodeZenClaudeBaseURL = \"https://opencode.ai/zen\"\n\topenCodeGoClaudeBaseURL = \"https://opencode.ai/zen/go\"\n\txfyunClaudeBaseURL = \"https://maas-coding-api.cn-huabei-1.xf-yun.com/anthropic\"\n\txfyunCodexBaseURL = \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v2\"\n\ttencentLkeapClaudeBaseURL = \"https://api.lkeap.cloud.tencent.com/plan/anthropic\"\n\tvolcArkClaudeBaseURL = \"https://ark.cn-beijing.volces.com/api/coding\"\n\tqianfanClaudeBaseURL = \"https://qianfan.baidubce.com/anthropic/coding\"\n\ttencentLkeapCodexBaseURL = \"https://api.lkeap.cloud.tencent.com/plan/v3\"\n\tvolcArkCodexBaseURL = \"https://ark.cn-beijing.volces.com/api/coding/v3\"\n\tqianfanCodexBaseURL = \"https://qianfan.baidubce.com/v2/coding\"\n\tmodelScopeCodexBaseURL = \"https://api-inference.modelscope.cn/v1\"\n\tstateVersion = 1\n\n\topenCodeZenBaseURL = \"https://opencode.ai/zen/v1\"\n\topenCodeGoBaseURL = \"https://opencode.ai/zen/go/v1\"\n)\n\ntype AgentConfigStatus struct {\n\tPlatform string `json:\"platform\"`\n\tProvider string `json:\"provider,omitempty\"`\n\tMode string `json:\"mode,omitempty\"` // \"quick\" | \"plugin\"\n\tTargetProvider string `json:\"targetProvider,omitempty\"`\n\tConfigured bool `json:\"configured\"`\n\tMatchesCurrentPort bool `json:\"matchesCurrentPort\"`\n\tNeedsUpdate bool `json:\"needsUpdate\"`\n\tCurrentBaseURL string `json:\"currentBaseUrl\"`\n\tTargetBaseURL string `json:\"targetBaseUrl\"`\n\tConfigPath string `json:\"configPath\"`\n\tAuthPath string `json:\"authPath,omitempty\"`\n\tHasState bool `json:\"hasState\"`\n\tLastError string `json:\"lastError,omitempty\"`\n\n\t// Codex 专属:config.toml 与 auth.json 一致性诊断。\n\t// 用于识别 CCS 等工具或手工编辑导致的配置污染,避免用户只看到上游 503。\n\tAuthMode string `json:\"authMode,omitempty\"` // auth.json 的 auth_mode:\"apikey\" | \"chatgpt\"\n\tConfigConsistent bool `json:\"configConsistent\"` // config.toml 与 auth.json 语义是否一致\n\tDiagnosticCode string `json:\"diagnosticCode,omitempty\"` // 不一致时的诊断码,如 codex.missing_api_key\n\tDiagnosticMessage string `json:\"diagnosticMessage,omitempty\"` // 面向用户的诊断说明\n}\n\ntype ApplyAgentConfigRequest struct {\n\tPlatform string `json:\"platform\"`\n\tProvider string `json:\"provider,omitempty\"`\n\tAPIKey string `json:\"apiKey,omitempty\"`\n\tBaseURL string `json:\"baseUrl,omitempty\"`\n\tMode string `json:\"mode,omitempty\"` // \"quick\" | \"plugin\"\n}\n\ntype MigrateCodexSessionsRequest struct {\n\tProvider string `json:\"provider,omitempty\"`\n\tMode string `json:\"mode,omitempty\"` // \"quick\" | \"plugin\"\n}\n\ntype MigrateCodexSessionsResult struct {\n\tTargetProvider string `json:\"targetProvider\"`\n\tTotalFiles int `json:\"totalFiles\"`\n\tMigratedFiles int `json:\"migratedFiles\"`\n\tSkippedFiles int `json:\"skippedFiles\"`\n\tFailedFiles int `json:\"failedFiles\"`\n\tSQLiteRowsUpdated int64 `json:\"sqliteRowsUpdated\"`\n\tSQLiteSkipped bool `json:\"sqliteSkipped\"`\n\tSQLiteError string `json:\"sqliteError,omitempty\"`\n}\n\ntype Service struct {\n\thomeDir string\n\tstateDir string\n}\n\ntype codexThreadsColumns struct {\n\tmodelProvider bool\n\tpreview bool\n\tfirstUserMessage bool\n\thasUserEvent bool\n\tthreadSource bool\n\tsource bool\n}\n\ntype ClaudeProxyState struct {\n\tVersion int `json:\"version\"`\n\tTargetPath string `json:\"targetPath\"`\n\tFileExisted bool `json:\"fileExisted\"`\n\tEnvExisted bool `json:\"envExisted\"`\n\tOriginalBaseURL *string `json:\"originalBaseUrl,omitempty\"`\n\tOriginalAuthToken *string `json:\"originalAuthToken,omitempty\"`\n\tOriginalAPIKey *string `json:\"originalApiKey,omitempty\"`\n\tOriginalModel *string `json:\"originalModel,omitempty\"`\n\tOriginalSmallFast *string `json:\"originalSmallFast,omitempty\"`\n\tInjectedProvider string `json:\"injectedProvider\"`\n\tInjectedBaseURL string `json:\"injectedBaseUrl\"`\n\tInjectedAuthToken string `json:\"injectedAuthToken,omitempty\"`\n\tInjectedAPIKey string `json:\"injectedApiKey,omitempty\"`\n}\n\ntype CodexProxyState struct {\n\tVersion int `json:\"version\"`\n\tConfigPath string `json:\"configPath\"`\n\tAuthPath string `json:\"authPath\"`\n\tConfigFileExisted bool `json:\"configFileExisted\"`\n\tAuthFileExisted bool `json:\"authFileExisted\"`\n\tOriginalModelProvider *string `json:\"originalModelProvider,omitempty\"`\n\tOriginalProviderBlock *string `json:\"originalProviderBlock,omitempty\"`\n\tOriginalOpenAIAPIKey *string `json:\"originalOpenaiApiKey,omitempty\"`\n\tOriginalOpenAIBaseURL *string `json:\"originalOpenaiBaseUrl,omitempty\"`\n\tInjectedProvider string `json:\"injectedProvider,omitempty\"`\n\tInjectedBaseURL string `json:\"injectedBaseUrl\"`\n\tInjectedAPIKey string `json:\"injectedApiKey\"`\n\tThirdPartyQuickMode bool `json:\"thirdPartyQuickMode,omitempty\"`\n}\n\ntype DiffLine struct {\n\tType string `json:\"type\"` // \"context\" | \"added\" | \"removed\"\n\tContent string `json:\"content\"`\n}\n\ntype FileDiff struct {\n\tPath string `json:\"path\"`\n\tAction string `json:\"action\"` // \"modify\" | \"create\" | \"delete\"\n\tLines []DiffLine `json:\"lines\"`\n}\n\ntype ConfigDiffResult struct {\n\tFiles []FileDiff `json:\"files\"`\n}\n\ntype ProviderKeyStore struct {\n\tVersion int `json:\"version\"`\n\tKeys map[string]string `json:\"keys\"`\n\tAssets map[string]ProviderKeyAsset `json:\"assets,omitempty\"`\n}\n\ntype ProviderKeyAsset struct {\n\tProvider string `json:\"provider\"`\n\tAPIKey string `json:\"apiKey\"`\n\tBaseURL string `json:\"baseUrl,omitempty\"`\n\tPlanID string `json:\"planId,omitempty\"`\n\tProxyURL string `json:\"proxyUrl,omitempty\"`\n\tUsages []string `json:\"usages,omitempty\"`\n}\n\nfunc New(dataDir string) (*Service, error) {\n\thomeDir, err := os.UserHomeDir()\n\tif err != nil || homeDir == \"\" {\n\t\treturn nil, fmt.Errorf(\"无法定位用户主目录\")\n\t}\n\tif dataDir == \"\" {\n\t\tdataDir = appdirs.DataDirForHome(homeDir)\n\t}\n\tstateDir := filepath.Join(dataDir, \"agent-config-state\")\n\tif err := os.MkdirAll(stateDir, 0o755); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &Service{homeDir: homeDir, stateDir: stateDir}, nil\n}\n\nfunc (s *Service) GetStatus(platform string, port int) (AgentConfigStatus, error) {\n\tswitch platform {\n\tcase PlatformClaude:\n\t\treturn s.getClaudeStatus(port)\n\tcase PlatformCodex:\n\t\treturn s.getCodexStatus(port)\n\tcase PlatformOpenCode:\n\t\treturn s.getOpenCodeStatus(port)\n\tdefault:\n\t\treturn AgentConfigStatus{}, fmt.Errorf(\"不支持的 agent 平台: %s\", platform)\n\t}\n}\n\nfunc (s *Service) Apply(req ApplyAgentConfigRequest, port int, accessKey string) error {\n\tplatform := strings.TrimSpace(req.Platform)\n\tif platform == \"\" {\n\t\treturn fmt.Errorf(\"agent 平台不能为空\")\n\t}\n\tswitch platform {\n\tcase PlatformClaude:\n\t\treturn s.applyClaude(req, port, accessKey)\n\tcase PlatformCodex:\n\t\tprovider := strings.TrimSpace(req.Provider)\n\t\tif provider == ProviderOpenAI {\n\t\t\treturn s.applyCodexOpenAI(req.APIKey)\n\t\t}\n\t\tif responsesURL, ok := codexResponsesBaseURL(provider); ok {\n\t\t\tif baseURL := strings.TrimSpace(req.BaseURL); baseURL != \"\" {\n\t\t\t\tresponsesURL = baseURL\n\t\t\t}\n\t\t\tif req.Mode == \"quick\" {\n\t\t\t\treturn s.applyCodexThirdPartyQuick(provider, responsesURL, req.APIKey)\n\t\t\t}\n\t\t\treturn s.applyCodexThirdParty(provider, responsesURL, req.APIKey)\n\t\t}\n\t\tif port == 0 {\n\t\t\treturn fmt.Errorf(\"CCX 端口未设置\")\n\t\t}\n\t\tif accessKey == \"\" {\n\t\t\treturn fmt.Errorf(\"PROXY_ACCESS_KEY 为空\")\n\t\t}\n\t\treturn s.applyCodex(port, accessKey, req.Mode)\n\tcase PlatformOpenCode:\n\t\treturn s.applyOpenCode(req, port, accessKey)\n\tdefault:\n\t\treturn fmt.Errorf(\"不支持的 agent 平台: %s\", platform)\n\t}\n}\n\nfunc (s *Service) Restore(platform string) error {\n\tswitch platform {\n\tcase PlatformClaude:\n\t\treturn s.restoreClaude()\n\tcase PlatformCodex:\n\t\treturn s.restoreCodex()\n\tcase PlatformOpenCode:\n\t\treturn s.restoreOpenCode()\n\tdefault:\n\t\treturn fmt.Errorf(\"不支持的 agent 平台: %s\", platform)\n\t}\n}\n\nfunc (s *Service) PreviewApply(req ApplyAgentConfigRequest, port int, accessKey string) (ConfigDiffResult, error) {\n\tplatform := strings.TrimSpace(req.Platform)\n\tif platform == \"\" {\n\t\treturn ConfigDiffResult{}, fmt.Errorf(\"agent 平台不能为空\")\n\t}\n\tswitch platform {\n\tcase PlatformClaude:\n\t\treturn s.previewApplyClaude(req, port, accessKey)\n\tcase PlatformCodex:\n\t\tprovider := strings.TrimSpace(req.Provider)\n\t\tif provider == ProviderOpenAI {\n\t\t\treturn s.previewApplyCodexOpenAI(req.APIKey)\n\t\t}\n\t\tif responsesURL, ok := codexResponsesBaseURL(provider); ok {\n\t\t\tif baseURL := strings.TrimSpace(req.BaseURL); baseURL != \"\" {\n\t\t\t\tresponsesURL = baseURL\n\t\t\t}\n\t\t\tif req.Mode == \"quick\" {\n\t\t\t\treturn s.previewApplyCodexThirdPartyQuick(provider, responsesURL, req.APIKey)\n\t\t\t}\n\t\t\treturn s.previewApplyCodexThirdParty(provider, responsesURL, req.APIKey)\n\t\t}\n\t\treturn s.previewApplyCodex(port, accessKey, req.Mode)\n\tcase PlatformOpenCode:\n\t\treturn s.previewApplyOpenCode(req, port, accessKey)\n\tdefault:\n\t\treturn ConfigDiffResult{}, fmt.Errorf(\"不支持的 agent 平台: %s\", platform)\n\t}\n}\n\n// PreviewRestore 预览 Restore 操作的变更,不实际写入文件。\n\nfunc (s *Service) PreviewRestore(platform string) (ConfigDiffResult, error) {\n\tswitch platform {\n\tcase PlatformClaude:\n\t\treturn s.previewRestoreClaude()\n\tcase PlatformCodex:\n\t\treturn s.previewRestoreCodex()\n\tcase PlatformOpenCode:\n\t\treturn s.previewRestoreOpenCode()\n\tdefault:\n\t\treturn ConfigDiffResult{}, fmt.Errorf(\"不支持的 agent 平台: %s\", platform)\n\t}\n}\n"}, "4da6d77e15a5c2ac4bc15ce1ad1a68cfdbd2e2d0": {"t": 1785846960.1942456, "n": 11141, "h": "16e7724a667b5f6e", "c": "package configservice\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/BenedictKing/ccx/desktop/internal/appdirs\"\n)\n\nconst (\n\tPlatformClaude = \"claude\"\n\tPlatformCodex = \"codex\"\n\tPlatformOpenCode = \"opencode\"\n\n\tProviderCCX = \"ccx\"\n\tProviderDeepSeek = \"deepseek\"\n\tProviderMiMo = \"mimo\"\n\tProviderCompshare = \"compshare\"\n\tProviderRunAPI = \"runapi\"\n\tProviderUnity2 = \"unity2\"\n\tProviderKimi = \"kimi\"\n\tProviderGLM = \"glm\"\n\tProviderSenseNova = \"sensenova\"\n\tProviderMiniMax = \"minimax\"\n\tProviderDashScope = \"dashscope\"\n\tProviderOpenCodeZen = \"opencode-zen\"\n\tProviderOpenCodeGo = \"opencode-go\"\n\tProviderCustom = \"custom\"\n\tProviderOpenAI = \"openai\"\n\tProviderXFyun = \"xfyun\"\n\tProviderTencentLkeap = \"tencent-lkeap\"\n\tProviderVolcArk = \"volc-ark\"\n\tProviderQianfan = \"qianfan\"\n\tProviderModelScope = \"modelscope\"\n\n\tdeepSeekClaudeBaseURL = \"https://api.deepseek.com/anthropic\"\n\tdefaultMiMoBaseURL = \"https://api.xiaomimimo.com/anthropic\"\n\tcompshareClaudeBaseURL = \"https://cp.compshare.cn\"\n\trunAPIBaseURL = \"https://runapi.co/v1\"\n\tunity2BaseURL = \"https://unity2.ai/v1\"\n\tkimiClaudeBaseURL = \"https://api.moonshot.cn/anthropic\"\n\tglmClaudeBaseURL = \"https://open.bigmodel.cn/api/anthropic\"\n\tsensenovaClaudeBaseURL = \"https://token.sensenova.cn\"\n\tsensenovaChatBaseURL = \"https://token.sensenova.cn/v1\"\n\tminiMaxClaudeBaseURL = \"https://api.minimaxi.com/anthropic\"\n\tdashScopeClaudeBaseURL = \"https://dashscope.aliyuncs.com/apps/anthropic\"\n\tdashScopeCodingPlanClaudeBaseURL = \"https://coding.dashscope.aliyuncs.com/apps/anthropic\"\n\topenCodeZenClaudeBaseURL = \"https://opencode.ai/zen\"\n\topenCodeGoClaudeBaseURL = \"https://opencode.ai/zen/go\"\n\txfyunClaudeBaseURL = \"https://maas-coding-api.cn-huabei-1.xf-yun.com/anthropic\"\n\txfyunCodexBaseURL = \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v2\"\n\ttencentLkeapClaudeBaseURL = \"https://api.lkeap.cloud.tencent.com/plan/anthropic\"\n\tvolcArkClaudeBaseURL = \"https://ark.cn-beijing.volces.com/api/coding\"\n\tqianfanClaudeBaseURL = \"https://qianfan.baidubce.com/anthropic/coding\"\n\ttencentLkeapCodexBaseURL = \"https://api.lkeap.cloud.tencent.com/plan/v3\"\n\tvolcArkCodexBaseURL = \"https://ark.cn-beijing.volces.com/api/coding/v3\"\n\tqianfanCodexBaseURL = \"https://qianfan.baidubce.com/v2/coding\"\n\tmodelScopeCodexBaseURL = \"https://api-inference.modelscope.cn/v1\"\n\tstateVersion = 1\n\n\topenCodeZenBaseURL = \"https://opencode.ai/zen/v1\"\n\topenCodeGoBaseURL = \"https://opencode.ai/zen/go/v1\"\n)\n\ntype AgentConfigStatus struct {\n\tPlatform string `json:\"platform\"`\n\tProvider string `json:\"provider,omitempty\"`\n\tMode string `json:\"mode,omitempty\"` // \"quick\" | \"plugin\"\n\tTargetProvider string `json:\"targetProvider,omitempty\"`\n\tConfigured bool `json:\"configured\"`\n\tMatchesCurrentPort bool `json:\"matchesCurrentPort\"`\n\tNeedsUpdate bool `json:\"needsUpdate\"`\n\tCurrentBaseURL string `json:\"currentBaseUrl\"`\n\tTargetBaseURL string `json:\"targetBaseUrl\"`\n\tConfigPath string `json:\"configPath\"`\n\tAuthPath string `json:\"authPath,omitempty\"`\n\tHasState bool `json:\"hasState\"`\n\tLastError string `json:\"lastError,omitempty\"`\n\n\t// Codex 专属:config.toml 与 auth.json 一致性诊断。\n\t// 用于识别 CCS 等工具或手工编辑导致的配置污染,避免用户只看到上游 503。\n\tAuthMode string `json:\"authMode,omitempty\"` // auth.json 的 auth_mode:\"apikey\" | \"chatgpt\"\n\tConfigConsistent bool `json:\"configConsistent\"` // config.toml 与 auth.json 语义是否一致\n\tDiagnosticCode string `json:\"diagnosticCode,omitempty\"` // 不一致时的诊断码,如 codex.missing_api_key\n\tDiagnosticMessage string `json:\"diagnosticMessage,omitempty\"` // 面向用户的诊断说明\n}\n\ntype ApplyAgentConfigRequest struct {\n\tPlatform string `json:\"platform\"`\n\tProvider string `json:\"provider,omitempty\"`\n\tAPIKey string `json:\"apiKey,omitempty\"`\n\tBaseURL string `json:\"baseUrl,omitempty\"`\n\tMode string `json:\"mode,omitempty\"` // \"quick\" | \"plugin\"\n}\n\ntype MigrateCodexSessionsRequest struct {\n\tProvider string `json:\"provider,omitempty\"`\n\tMode string `json:\"mode,omitempty\"` // \"quick\" | \"plugin\"\n}\n\ntype MigrateCodexSessionsResult struct {\n\tTargetProvider string `json:\"targetProvider\"`\n\tTotalFiles int `json:\"totalFiles\"`\n\tMigratedFiles int `json:\"migratedFiles\"`\n\tSkippedFiles int `json:\"skippedFiles\"`\n\tFailedFiles int `json:\"failedFiles\"`\n\tSQLiteRowsUpdated int64 `json:\"sqliteRowsUpdated\"`\n\tSQLiteSkipped bool `json:\"sqliteSkipped\"`\n\tSQLiteError string `json:\"sqliteError,omitempty\"`\n}\n\ntype Service struct {\n\thomeDir string\n\tstateDir string\n}\n\ntype codexThreadsColumns struct {\n\tmodelProvider bool\n\tpreview bool\n\tfirstUserMessage bool\n\thasUserEvent bool\n\tthreadSource bool\n\tsource bool\n}\n\ntype ClaudeProxyState struct {\n\tVersion int `json:\"version\"`\n\tTargetPath string `json:\"targetPath\"`\n\tFileExisted bool `json:\"fileExisted\"`\n\tEnvExisted bool `json:\"envExisted\"`\n\tOriginalBaseURL *string `json:\"originalBaseUrl,omitempty\"`\n\tOriginalAuthToken *string `json:\"originalAuthToken,omitempty\"`\n\tOriginalAPIKey *string `json:\"originalApiKey,omitempty\"`\n\tOriginalModel *string `json:\"originalModel,omitempty\"`\n\tOriginalSmallFast *string `json:\"originalSmallFast,omitempty\"`\n\tInjectedProvider string `json:\"injectedProvider\"`\n\tInjectedBaseURL string `json:\"injectedBaseUrl\"`\n\tInjectedAuthToken string `json:\"injectedAuthToken,omitempty\"`\n\tInjectedAPIKey string `json:\"injectedApiKey,omitempty\"`\n}\n\ntype CodexProxyState struct {\n\tVersion int `json:\"version\"`\n\tConfigPath string `json:\"configPath\"`\n\tAuthPath string `json:\"authPath\"`\n\tConfigFileExisted bool `json:\"configFileExisted\"`\n\tAuthFileExisted bool `json:\"authFileExisted\"`\n\tOriginalModelProvider *string `json:\"originalModelProvider,omitempty\"`\n\tOriginalProviderBlock *string `json:\"originalProviderBlock,omitempty\"`\n\tOriginalOpenAIAPIKey *string `json:\"originalOpenaiApiKey,omitempty\"`\n\tOriginalOpenAIBaseURL *string `json:\"originalOpenaiBaseUrl,omitempty\"`\n\tInjectedProvider string `json:\"injectedProvider,omitempty\"`\n\tInjectedBaseURL string `json:\"injectedBaseUrl\"`\n\tInjectedAPIKey string `json:\"injectedApiKey\"`\n\tThirdPartyQuickMode bool `json:\"thirdPartyQuickMode,omitempty\"`\n}\n\ntype DiffLine struct {\n\tType string `json:\"type\"` // \"context\" | \"added\" | \"removed\"\n\tContent string `json:\"content\"`\n}\n\ntype FileDiff struct {\n\tPath string `json:\"path\"`\n\tAction string `json:\"action\"` // \"modify\" | \"create\" | \"delete\"\n\tLines []DiffLine `json:\"lines\"`\n}\n\ntype ConfigDiffResult struct {\n\tFiles []FileDiff `json:\"files\"`\n}\n\ntype ProviderKeyStore struct {\n\tVersion int `json:\"version\"`\n\tKeys map[string]string `json:\"keys\"`\n\tAssets map[string]ProviderKeyAsset `json:\"assets,omitempty\"`\n}\n\ntype ProviderKeyAsset struct {\n\tProvider string `json:\"provider\"`\n\tAPIKey string `json:\"apiKey\"`\n\tBaseURL string `json:\"baseUrl,omitempty\"`\n\tPlanID string `json:\"planId,omitempty\"`\n\tProxyURL string `json:\"proxyUrl,omitempty\"`\n\tUsages []string `json:\"usages,omitempty\"`\n}\n\nfunc New(dataDir string) (*Service, error) {\n\thomeDir, err := os.UserHomeDir()\n\tif err != nil || homeDir == \"\" {\n\t\treturn nil, fmt.Errorf(\"无法定位用户主目录\")\n\t}\n\tif dataDir == \"\" {\n\t\tdataDir = appdirs.DataDirForHome(homeDir)\n\t}\n\tstateDir := filepath.Join(dataDir, \"agent-config-state\")\n\tif err := os.MkdirAll(stateDir, 0o755); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &Service{homeDir: homeDir, stateDir: stateDir}, nil\n}\n\nfunc (s *Service) GetStatus(platform string, port int) (AgentConfigStatus, error) {\n\tswitch platform {\n\tcase PlatformClaude:\n\t\treturn s.getClaudeStatus(port)\n\tcase PlatformCodex:\n\t\treturn s.getCodexStatus(port)\n\tcase PlatformOpenCode:\n\t\treturn s.getOpenCodeStatus(port)\n\tdefault:\n\t\treturn AgentConfigStatus{}, fmt.Errorf(\"不支持的 agent 平台: %s\", platform)\n\t}\n}\n\nfunc (s *Service) Apply(req ApplyAgentConfigRequest, port int, accessKey string) error {\n\tplatform := strings.TrimSpace(req.Platform)\n\tif platform == \"\" {\n\t\treturn fmt.Errorf(\"agent 平台不能为空\")\n\t}\n\tswitch platform {\n\tcase PlatformClaude:\n\t\treturn s.applyClaude(req, port, accessKey)\n\tcase PlatformCodex:\n\t\tprovider := strings.TrimSpace(req.Provider)\n\t\tif provider == ProviderOpenAI {\n\t\t\treturn s.applyCodexOpenAI(req.APIKey)\n\t\t}\n\t\tif responsesURL, ok := codexResponsesBaseURL(provider); ok {\n\t\t\tif baseURL := strings.TrimSpace(req.BaseURL); baseURL != \"\" {\n\t\t\t\tresponsesURL = baseURL\n\t\t\t}\n\t\t\tif req.Mode == \"quick\" {\n\t\t\t\treturn s.applyCodexThirdPartyQuick(provider, responsesURL, req.APIKey)\n\t\t\t}\n\t\t\treturn s.applyCodexThirdParty(provider, responsesURL, req.APIKey)\n\t\t}\n\t\tif port == 0 {\n\t\t\treturn fmt.Errorf(\"CCX 端口未设置\")\n\t\t}\n\t\tif accessKey == \"\" {\n\t\t\treturn fmt.Errorf(\"PROXY_ACCESS_KEY 为空\")\n\t\t}\n\t\treturn s.applyCodex(port, accessKey, req.Mode)\n\tcase PlatformOpenCode:\n\t\treturn s.applyOpenCode(req, port, accessKey)\n\tdefault:\n\t\treturn fmt.Errorf(\"不支持的 agent 平台: %s\", platform)\n\t}\n}\n\nfunc (s *Service) Restore(platform string) error {\n\tswitch platform {\n\tcase PlatformClaude:\n\t\treturn s.restoreClaude()\n\tcase PlatformCodex:\n\t\treturn s.restoreCodex()\n\tcase PlatformOpenCode:\n\t\treturn s.restoreOpenCode()\n\tdefault:\n\t\treturn fmt.Errorf(\"不支持的 agent 平台: %s\", platform)\n\t}\n}\n\nfunc (s *Service) PreviewApply(req ApplyAgentConfigRequest, port int, accessKey string) (ConfigDiffResult, error) {\n\tplatform := strings.TrimSpace(req.Platform)\n\tif platform == \"\" {\n\t\treturn ConfigDiffResult{}, fmt.Errorf(\"agent 平台不能为空\")\n\t}\n\tswitch platform {\n\tcase PlatformClaude:\n\t\treturn s.previewApplyClaude(req, port, accessKey)\n\tcase PlatformCodex:\n\t\tprovider := strings.TrimSpace(req.Provider)\n\t\tif provider == ProviderOpenAI {\n\t\t\treturn s.previewApplyCodexOpenAI(req.APIKey)\n\t\t}\n\t\tif responsesURL, ok := codexResponsesBaseURL(provider); ok {\n\t\t\tif baseURL := strings.TrimSpace(req.BaseURL); baseURL != \"\" {\n\t\t\t\tresponsesURL = baseURL\n\t\t\t}\n\t\t\tif req.Mode == \"quick\" {\n\t\t\t\treturn s.previewApplyCodexThirdPartyQuick(provider, responsesURL, req.APIKey)\n\t\t\t}\n\t\t\treturn s.previewApplyCodexThirdParty(provider, responsesURL, req.APIKey)\n\t\t}\n\t\treturn s.previewApplyCodex(port, accessKey, req.Mode)\n\tcase PlatformOpenCode:\n\t\treturn s.previewApplyOpenCode(req, port, accessKey)\n\tdefault:\n\t\treturn ConfigDiffResult{}, fmt.Errorf(\"不支持的 agent 平台: %s\", platform)\n\t}\n}\n\n// PreviewRestore 预览 Restore 操作的变更,不实际写入文件。\n\nfunc (s *Service) PreviewRestore(platform string) (ConfigDiffResult, error) {\n\tswitch platform {\n\tcase PlatformClaude:\n\t\treturn s.previewRestoreClaude()\n\tcase PlatformCodex:\n\t\treturn s.previewRestoreCodex()\n\tcase PlatformOpenCode:\n\t\treturn s.previewRestoreOpenCode()\n\tdefault:\n\t\treturn ConfigDiffResult{}, fmt.Errorf(\"不支持的 agent 平台: %s\", platform)\n\t}\n}\n"}}, "desktop/internal/channelpreset/preset.go": {"784041ebc2a680321b17c44db2f89e95b9470dbe": {"t": 1785809840.5048518, "n": 35122, "h": "07156f086edce1d3", "c": "package channelpreset\n\nimport (\n\t\"fmt\"\n\t\"maps\"\n\t\"slices\"\n\t\"strings\"\n)\n\nconst (\n\tProviderDeepSeek = \"deepseek\"\n\tProviderMiMo = \"mimo\"\n\tProviderCompshare = \"compshare\"\n\tProviderRunAPI = \"runapi\"\n\tProviderUnity2 = \"unity2\"\n\tProviderKimi = \"kimi\"\n\tProviderGLM = \"glm\"\n\tProviderSenseNova = \"sensenova\"\n\tProviderMiniMax = \"minimax\"\n\tProviderDashScope = \"dashscope\"\n\tProviderTencentLkeap = \"tencent-lkeap\"\n\tProviderKimiCode = \"kimi-code\"\n\tProviderVolcArk = \"volc-ark\"\n\tProviderQianfan = \"qianfan\"\n\tProviderXFyun = \"xfyun\"\n\tProviderOriginRouter = \"originrouter\"\n\tProviderOpenRouter = \"openrouter\"\n\tProviderModelScope = \"modelscope\"\n\tProviderOpenCodeZen = \"opencode-zen\"\n\tProviderOpenCodeGo = \"opencode-go\"\n\tProviderGitHubCopilot = \"github-copilot\"\n\n\tTargetMessages = \"messages\"\n\tTargetChat = \"chat\"\n\tTargetResponses = \"responses\"\n\tTargetGemini = \"gemini\"\n)\n\ntype ProviderPreset struct {\n\tID string `json:\"id\"`\n\tOrder int `json:\"order\"`\n\tLabel string `json:\"label\"`\n\tDescription string `json:\"description\"`\n\tDirectAgent bool `json:\"directAgent\"`\n\tNativeMessages bool `json:\"nativeMessages\"`\n\tChatCompatible bool `json:\"chatCompatible\"`\n\tResponsesCompatible bool `json:\"responsesCompatible\"`\n\tPlans []ProviderPlan `json:\"plans\"`\n\tTargets []ChannelTarget `json:\"targets\"`\n\tDefaultTarget string `json:\"defaultTarget\"`\n}\n\ntype ProviderPlan struct {\n\tID string `json:\"id\"`\n\tLabel string `json:\"label\"`\n\tBaseURL string `json:\"baseUrl\"`\n\tDescription string `json:\"description\"`\n\tRecommended bool `json:\"recommended\"`\n\tCustom bool `json:\"custom\"`\n}\n\n// Protocol 返回 plan 所属的协议类型。\nfunc (p ProviderPlan) Protocol() string {\n\tid := strings.ToLower(p.ID)\n\tlabel := strings.ToLower(p.Label)\n\tbaseURL := strings.TrimRight(strings.ToLower(p.BaseURL), \"/\")\n\tif strings.Contains(id, \"responses\") || strings.Contains(label, \"responses\") || strings.HasSuffix(baseURL, \"/responses\") {\n\t\treturn \"responses\"\n\t}\n\tif strings.Contains(id, \"anthropic\") || strings.Contains(label, \"anthropic\") || strings.Contains(baseURL, \"anthropic\") || strings.HasSuffix(baseURL, \"/messages\") {\n\t\treturn \"anthropic\"\n\t}\n\treturn \"openai\"\n}\n\ntype ChannelTarget struct {\n\tType string `json:\"type\"`\n\tLabel string `json:\"label\"`\n\tDescription string `json:\"description\"`\n\tRecommended bool `json:\"recommended\"`\n}\n\ntype CreateChannelRequest struct {\n\tProvider string `json:\"provider\"`\n\tTarget string `json:\"target\"`\n\tPlanID string `json:\"planId\"`\n\tBaseURL string `json:\"baseUrl\"`\n\tAPIKey string `json:\"apiKey\"`\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tProxyURL string `json:\"proxyUrl\"`\n}\n\ntype CreateChannelResult struct {\n\tProvider string `json:\"provider\"`\n\tTarget string `json:\"target\"`\n\tName string `json:\"name\"`\n\tBaseURL string `json:\"baseUrl\"`\n\tMessage string `json:\"message\"`\n}\n\ntype ChannelPayload struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description,omitempty\"`\n\tWebsite string `json:\"website,omitempty\"`\n\tServiceType string `json:\"serviceType\"`\n\tAuthHeader string `json:\"authHeader,omitempty\"`\n\tBaseURL string `json:\"baseUrl\"`\n\tAPIKeys []string `json:\"apiKeys\"`\n\tProxyURL string `json:\"proxyUrl,omitempty\"`\n\tModelMapping map[string]string `json:\"modelMapping,omitempty\"`\n\tReasoningMapping map[string]string `json:\"reasoningMapping,omitempty\"`\n\tReasoningParamStyle string `json:\"reasoningParamStyle,omitempty\"`\n\tNormalizeSystemRoleToTopLevel bool `json:\"normalizeSystemRoleToTopLevel,omitempty\"`\n\tStripBillingHeader bool `json:\"stripBillingHeader,omitempty\"`\n\tNoVision bool `json:\"noVision,omitempty\"`\n\tNoVisionModels []string `json:\"noVisionModels,omitempty\"`\n\tVisionFallbackModel string `json:\"visionFallbackModel,omitempty\"`\n\tSupportedModels []string `json:\"supportedModels,omitempty\"`\n\tInjectDummyThoughtSignature bool `json:\"injectDummyThoughtSignature,omitempty\"`\n\tStripThoughtSignature bool `json:\"stripThoughtSignature,omitempty\"`\n\tCodexToolCompat bool `json:\"codexToolCompat,omitempty\"`\n\tStripCodexClientTools bool `json:\"stripCodexClientTools,omitempty\"`\n\tNormalizeMetadataUserId *bool `json:\"normalizeMetadataUserId,omitempty\"`\n\tRateLimitRPM int `json:\"rateLimitRpm,omitempty\"`\n\tRateLimitBurst int `json:\"rateLimitBurst,omitempty\"`\n\tRateLimitMaxConcurrent int `json:\"rateLimitMaxConcurrent,omitempty\"`\n\tRateLimitAutoFromHeaders bool `json:\"rateLimitAutoFromHeaders,omitempty\"`\n\tPriority int `json:\"priority,omitempty\"`\n\tStatus string `json:\"status,omitempty\"`\n}\n\nvar providerConsoleURLs = map[string]string{\n\tProviderDeepSeek: \"https://platform.deepseek.com/usage\",\n\tProviderMiMo: \"https://platform.xiaomimimo.com/console/balance\",\n\tProviderCompshare: \"https://console.compshare.cn/light-gpu/model-subscription\",\n\tProviderRunAPI: \"https://runapi.co/console\",\n\tProviderUnity2: \"https://unity2.ai/dashboard\",\n\tProviderKimi: \"https://platform.moonshot.cn/console/account\",\n\tProviderGLM: \"https://open.bigmodel.cn/coding-plan/personal/overview\",\n\tProviderSenseNova: \"https://platform.sensenova.cn/console\",\n\tProviderMiniMax: \"https://platform.minimaxi.com/user-center/payment/balance\",\n\tProviderDashScope: \"https://bailian.console.aliyun.com/cn-beijing?tab=model#/api-key\",\n\tProviderTencentLkeap: \"https://console.cloud.tencent.com/lkeap/token-plan\",\n\tProviderKimiCode: \"https://www.kimi.com/code/console\",\n\tProviderVolcArk: \"https://console.volcengine.com/ark/region:cn-beijing/subscription/coding-plan?projectName=default\",\n\tProviderQianfan: \"https://console.bce.baidu.com/qianfan/resource/subscribe\",\n\tProviderXFyun: \"https://console.xfyun.cn/\",\n\tProviderOriginRouter: \"https://easytransnote.com/ai/console/#key\",\n\tProviderOpenRouter: \"https://openrouter.ai/keys\",\n\tProviderModelScope: \"https://modelscope.cn/my/myaccesstoken\",\n\tProviderOpenCodeZen: \"https://opencode.ai/\",\n\tProviderOpenCodeGo: \"https://opencode.ai/\",\n}\n\n// defaultTargets 返回标准的三目标配置(Messages、Responses、Chat),Messages 默认推荐。\nfunc defaultTargets() []ChannelTarget {\n\treturn []ChannelTarget{\n\t\t{Type: TargetMessages, Label: \"Claude Messages\", Description: \"Claude native Messages protocol, supports Claude Code direct or via CCX proxy\", Recommended: true},\n\t\t{Type: TargetResponses, Label: \"Codex Responses\", Description: \"OpenAI Responses protocol, for Codex CLI and compatible clients\"},\n\t\t{Type: TargetChat, Label: \"OpenAI Chat\", Description: \"OpenAI Chat Completions protocol, compatible with various Chat clients and third-party tools\"},\n\t}\n}\n\n// newFullCapabilityPreset 创建一个具有完整能力的 Preset 模板(全协议支持、全目标、默认 Messages)。\n// 调用者只需设置 ID、Order、Label、Description 和 Plans。\nfunc newFullCapabilityPreset(id, label, description string, order int, plans []ProviderPlan) ProviderPreset {\n\treturn ProviderPreset{\n\t\tID: id,\n\t\tOrder: order,\n\t\tLabel: label,\n\t\tDescription: description,\n\t\tDirectAgent: true,\n\t\tNativeMessages: true,\n\t\tChatCompatible: true,\n\t\tResponsesCompatible: true,\n\t\tPlans: plans,\n\t\tTargets: defaultTargets(),\n\t\tDefaultTarget: TargetMessages,\n\t}\n}\n\n// dualProtocolPlans 生成标准的双协议 Plans(Anthropic + OpenAI)。\n// anthropicURL 是 Anthropic 协议的完整 URL。\n// openaiURL 是 OpenAI 协议的完整 URL。\nfunc dualProtocolPlans(anthropicURL, openaiURL string) []ProviderPlan {\n\treturn []ProviderPlan{\n\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: anthropicURL, Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: openaiURL, Description: \"Chat / Responses 通用入口\"},\n\t}\n}\n\n// dualProtocolPlansSimple 生成标准的双协议 Plans,anthropic 和 openai 使用相同的 baseURL。\n// 适用于 RunAPI、Unity2 等统一入口的服务商。\nfunc dualProtocolPlansSimple(baseURL string) []ProviderPlan {\n\treturn dualProtocolPlans(baseURL, baseURL)\n}\n\nfunc Presets() []ProviderPreset {\n\treturn []ProviderPreset{\n\t\tnewFullCapabilityPreset(\n\t\t\tProviderDeepSeek,\n\t\t\t\"DeepSeek\",\n\t\t\t\"国产高性能推理模型,V4-Pro / V4-Flash 双旗舰,以极低成本实现顶级推理能力,完全开源(MIT),性价比极高。\",\n\t\t\t10,\n\t\t\tdualProtocolPlans(\"https://api.deepseek.com/anthropic\", \"https://api.deepseek.com/v1\"),\n\t\t),\n\t\t{\n\t\t\tID: ProviderMiMo,\n\t\t\tOrder: 20,\n\t\t\tLabel: \"MiMo\",\n\t\t\tDescription: \"小米自研大模型,面向 Agent 时代,旗舰 MiMo-V2.5-Pro 万亿参数、支持百万上下文,深度赋能人车家全生态。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"按量 (Anthropic)\", BaseURL: \"https://api.xiaomimimo.com/anthropic\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"按量 (OpenAI)\", BaseURL: \"https://api.xiaomimimo.com/v1\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t\t{ID: \"token-cn\", Label: \"Token Plan - 中国 (OpenAI)\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Description: \"中国区 Token Plan Chat / Responses 通用入口\"},\n\t\t\t\t{ID: \"token-sgp\", Label: \"Token Plan - 新加坡 (OpenAI)\", BaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\", Description: \"新加坡区 Token Plan Chat / Responses 通用入口\"},\n\t\t\t\t{ID: \"token-ams\", Label: \"Token Plan - 欧洲 (OpenAI)\", BaseURL: \"https://token-plan-ams.xiaomimimo.com/v1\", Description: \"欧洲区 Token Plan Chat / Responses 通用入口\"},\n\t\t\t\t{ID: \"token-cn-anthropic\", Label: \"Token Plan - 中国 (Anthropic)\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/anthropic\", Description: \"中国区 Token Plan Claude Messages 原生入口\"},\n\t\t\t\t{ID: \"token-sgp-anthropic\", Label: \"Token Plan - 新加坡 (Anthropic)\", BaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\", Description: \"新加坡区 Token Plan Claude Messages 原生入口\"},\n\t\t\t\t{ID: \"token-ams-anthropic\", Label: \"Token Plan - 欧洲 (Anthropic)\", BaseURL: \"https://token-plan-ams.xiaomimimo.com/anthropic\", Description: \"欧洲区 Token Plan Claude Messages 原生入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\tnewFullCapabilityPreset(\n\t\t\tProviderCompshare,\n\t\t\t\"优云智算套餐\",\n\t\t\t\"优云智算是 UCloud 旗下 AI 云平台,提供高性价比国内 AI 模型 Agent Plan 套餐,支持包月订阅或按量付费(49 元/月起),同时提供官方海外模型稳定接入,支持 Claude Code、Codex 与 API 集成,具备企业级高并发、7×24 技术支持和自助开票能力;通过推广链接注册可领取 5 元平台试用金。\",\n\t\t\t30,\n\t\t\tdualProtocolPlans(\"https://cp.compshare.cn\", \"https://cp.compshare.cn/v1\"),\n\t\t),\n\t\tnewFullCapabilityPreset(\n\t\t\tProviderRunAPI,\n\t\t\t\"RunAPI\",\n\t\t\t\"RunAPI 是高效稳定的API OpenRouter平替平台,一个 API Key 即可访问 OpenAI、Claude、Gemini、DeepSeek、Grok 等 150+ 主流模型,低至 1 折,极其稳定,可以无缝兼容 Claude Code、OpenClaw 等工具。RunAPI 为 CCX用户提供专属福利:注册联系管理员即可领取¥7的免费额度\",\n\t\t\t40,\n\t\t\tdualProtocolPlansSimple(\"https://runapi.co/v1\"),\n\t\t),\n\t\tnewFullCapabilityPreset(\n\t\t\tProviderUnity2,\n\t\t\t\"Unity2.ai\",\n\t\t\t\"Unity2.ai 是面向个人开发者、团队、企业的高性能 AI 模型 API 中转平台,长期服务国内头部企业,日均承载超 300 亿 token 调用,支持 5000 RPM 级高并发。一个 API Key 即可适配 Claude Code、Codex、OpenAI 模型、IDE 插件和 Agent 工作流等场景。具备企业级稳定供应能力,在高并发、持续调用和团队集中采购场景下依然保持低延迟、高可用。现在注册 Unity2.ai 可领取 $2 余额,加入官方群再送 $10 余额,合计最高可领 $12 免费额度。\",\n\t\t\t45,\n\t\t\tdualProtocolPlansSimple(\"https://unity2.ai/v1\"),\n\t\t),\n\t\t{\n\t\t\tID: ProviderKimi,\n\t\t\tOrder: 50,\n\t\t\tLabel: \"Kimi / Moonshot\",\n\t\t\tDescription: \"月之暗面旗下智能助手平台,旗舰 Kimi K2.6 原生多模态,以超长上下文和 Agent 集群能力著称,深度研究表现突出。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"按量 (Anthropic)\", BaseURL: \"https://api.moonshot.cn/anthropic\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"按量 (OpenAI)\", BaseURL: \"https://api.moonshot.cn/v1\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t\t{ID: \"coding-anthropic\", Label: \"Coding Plan (Anthropic)\", BaseURL: \"https://api.kimi.com/coding\", Description: \"Coding Plan Claude Messages 原生入口\"},\n\t\t\t\t{ID: \"coding-openai-chat\", Label: \"Coding Plan (OpenAI)\", BaseURL: \"https://api.kimi.com/coding/v1\", Description: \"Coding Plan Chat / Responses 通用入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderGLM,\n\t\t\tOrder: 60,\n\t\t\tLabel: \"GLM / BigModel\",\n\t\t\tDescription: \"智谱 AI 大模型开放平台,旗舰 GLM-5.1 编程能力对标前沿闭源模型,港股上市公司,全程华为昇腾训练,性价比突出。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://open.bigmodel.cn/api/anthropic\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"coding\", Label: \"Coding Plan (OpenAI)\", BaseURL: \"https://open.bigmodel.cn/api/coding/paas/v4#\", Description: \"Coding Plan Chat / Responses 通用入口\"},\n\t\t\t\t{ID: \"openai-chat\", Label: \"通用 (OpenAI)\", BaseURL: \"https://open.bigmodel.cn/api/paas/v4#\", Description: \"通用 Chat / Responses 入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderSenseNova,\n\t\t\tOrder: 65,\n\t\t\tLabel: \"SenseNova\",\n\t\t\tDescription: \"商汤日日新 SenseNova LLM API 平台,兼容 OpenAI 与 Anthropic 协议,支持轻量多模态模型和主流编程模型接入。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://token.sensenova.cn\", Description: \"Claude Messages 兼容入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://token.sensenova.cn/v1\", Description: \"Chat 入口,Responses 由 CCX 转换\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderMiniMax,\n\t\t\tOrder: 70,\n\t\t\tLabel: \"MiniMax\",\n\t\t\tDescription: \"全球化多模态 AI 公司(港股上市),旗舰 M3 支持百万上下文、原生视觉与 Agent 能力,海螺视频 / 音频模型全球第一梯队,海外收入超七成。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://api.minimaxi.com/anthropic\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://api.minimax.chat/v1\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderDashScope,\n\t\t\tOrder: 80,\n\t\t\tLabel: \"阿里云 DashScope\",\n\t\t\tDescription: \"阿里云一站式大模型服务平台,集成千问全系列及 DeepSeek 等主流模型,覆盖文本、图像、音视频全模态,提供按量、Coding Plan、Token Plan 多种计费。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"按量 (Anthropic)\", BaseURL: \"https://dashscope.aliyuncs.com/apps/anthropic\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"按量 (OpenAI)\", BaseURL: \"https://dashscope.aliyuncs.com/compatible-mode/v1\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t\t{ID: \"coding-anthropic\", Label: \"Coding Plan (Anthropic)\", BaseURL: \"https://coding.dashscope.aliyuncs.com/apps/anthropic\", Description: \"Coding Plan Claude Messages 原生入口\"},\n\t\t\t\t{ID: \"coding-openai-chat\", Label: \"Coding Plan (OpenAI)\", BaseURL: \"https://coding.dashscope.aliyuncs.com/v1\", Description: \"Coding Plan Chat / Responses 通用入口\"},\n\t\t\t\t{ID: \"token-plan-anthropic\", Label: \"Token Plan (Anthropic)\", BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic\", Description: \"Token Plan Claude Messages 原生入口\"},\n\t\t\t\t{ID: \"token-plan-openai-chat\", Label: \"Token Plan (OpenAI)\", BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1\", Description: \"Token Plan Chat / Responses 通用入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderOpenCodeZen,\n\t\t\tOrder: 90,\n\t\t\tLabel: \"OpenCode Zen / Go\",\n\t\t\tDescription: \"OpenCode 官方精选 AI 编程模型网关,按量 Zen 与低成本 Go 订阅共用同一套模型预设。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"go-openai-chat\", Label: \"Go (OpenAI-compatible)\", BaseURL: \"https://opencode.ai/zen/go/v1\", Description: \"订阅套餐入口,Messages 经 CCX 转 Chat 上游\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"Zen (OpenAI-compatible)\", BaseURL: \"https://opencode.ai/zen/v1\", Description: \"按量付费入口,Messages 经 CCX 转 Chat 上游\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderOpenCodeGo,\n\t\t\tOrder: 100,\n\t\t\tLabel: \"OpenCode Go\",\n\t\t\tDescription: \"OpenCode 低成本开源编程模型订阅服务($5/月起),深度优化编程 Agent 场景。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://opencode.ai/zen/go/v1\", Description: \"Claude Messages 原生入口\"},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://opencode.ai/zen/go/v1\", Description: \"Messages 经 CCX 转 Chat 上游,Chat / Responses 直连该入口\", Recommended: true},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderTencentLkeap,\n\t\t\tOrder: 85,\n\t\t\tLabel: \"腾讯云 TokenHub\",\n\t\t\tDescription: \"腾讯云大模型服务平台,整合混元及 DeepSeek、Kimi 等主流模型,提供 Token Plan 订阅和按量计费,适配 Claude Code、Codex 等主流编程工具。\",\n\t\t\tDirectAgent: false,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/anthropic\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/v3\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderVolcArk,\n\t\t\tOrder: 86,\n\t\t\tLabel: \"火山方舟 Coding Plan\",\n\t\t\tDescription: \"字节跳动火山引擎旗下一站式 AI 平台,提供豆包系列模型精调、推理、评测全链路服务,覆盖多厂商模型,企业级保障完善。\",\n\t\t\tDirectAgent: false,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://ark.cn-beijing.volces.com/api/coding\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderQianfan,\n\t\t\tOrder: 87,\n\t\t\tLabel: \"百度千帆 Coding Plan\",\n\t\t\tDescription: \"百度智能云企业级大模型平台,内置文心 5.0 原生全模态大模型,支持多智能体协同,提供从开发到部署的全链路工具。\",\n\t\t\tDirectAgent: false,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://qianfan.baidubce.com/anthropic/coding\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://qianfan.baidubce.com/v2/coding#\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderXFyun,\n\t\t\tOrder: 88,\n\t\t\tLabel: \"讯飞星辰\",\n\t\t\tDescription: \"科大讯飞星辰 MaaS Astron Coding Plan,提供 OpenAI、Anthropic 与 Responses 三个独立 Coding Plan 入口。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/anthropic\", Description: \"Claude Messages Coding Plan 入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v2\", Description: \"OpenAI Chat Coding Plan 入口\"},\n\t\t\t\t{ID: \"responses\", Label: \"Responses-compatible\", BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v1/responses\", Description: \"OpenAI Responses Coding Plan 入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderOpenRouter,\n\t\t\tOrder: 35,\n\t\t\tLabel: \"OpenRouter\",\n\t\t\tDescription: \"全球最大的 AI 模型聚合平台,一个 API 密钥访问数百个模型,兼容 OpenAI 接口,提供安全治理和预算管控能力。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://openrouter.ai/api\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://openrouter.ai/api/v1\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderModelScope,\n\t\t\tOrder: 82,\n\t\t\tLabel: \"ModelScope 魔搭\",\n\t\t\tDescription: \"阿里达摩院发起的开源模型社区,汇聚上万个模型,覆盖文本、图像、语音等全模态,支持模型探索、推理与训练全流程。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://api-inference.modelscope.cn\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://api-inference.modelscope.cn/v1\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\t// 极易云仅在渠道中心可用,刻意不纳入 Agent(Claude/Codex/OpenCode)直连下拉。\n\t\t\t// 即便三个协议标志均为 true,也不要将其加入 desktop/internal/configservice 的直连 provider 列表。\n\t\t\tID: ProviderOriginRouter,\n\t\t\tOrder: 200,\n\t\t\tLabel: \"极易云 OriginRouter\",\n\t\t\tDescription: \"极易云统一模型 API 转发平台,一个密钥无缝切换 GPT、Claude、Gemini 等主流模型,高速稳定,适合多模型混用场景。\",\n\t\t\tDirectAgent: false,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://api.easytransnote.com/coding\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://api.easytransnote.com/coding/v1\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t}\n}\n\nfunc BuildPayload(req CreateChannelRequest) (ChannelPayload, error) {\n\tif strings.EqualFold(strings.TrimSpace(req.Provider), ProviderGitHubCopilot) {\n\t\treturn buildGitHubCopilotPayload(req)\n\t}\n\n\tpreset, ok := FindPreset(req.Provider)\n\tif !ok {\n\t\treturn ChannelPayload{}, fmt.Errorf(\"不支持的 provider: %s\", req.Provider)\n\t}\n\ttarget := strings.TrimSpace(req.Target)\n\tif target == \"\" {\n\t\ttarget = preset.DefaultTarget\n\t}\n\tif !supportsTarget(preset, target) {\n\t\treturn ChannelPayload{}, fmt.Errorf(\"%s 不支持添加到 %s 渠道\", preset.Label, target)\n\t}\n\tapiKey := strings.TrimSpace(req.APIKey)\n\tif apiKey == \"\" {\n\t\treturn ChannelPayload{}, fmt.Errorf(\"API Key 不能为空\")\n\t}\n\tplanID := strings.TrimSpace(req.PlanID)\n\tif planID == \"\" {\n\t\tplanID = bestPlanForTarget(preset, target)\n\t}\n\tbaseURL, err := ResolveBaseURL(preset, planID, req.BaseURL)\n\tif err != nil {\n\t\treturn ChannelPayload{}, err\n\t}\n\tname := strings.TrimSpace(req.Name)\n\tif name == \"\" {\n\t\tname = defaultChannelNameForPlan(preset.ID, target, planID)\n\t}\n\tpayload := ChannelPayload{\n\t\tName: name,\n\t\tDescription: strings.TrimSpace(req.Description),\n\t\tWebsite: providerConsoleURLs[preset.ID],\n\t\tBaseURL: baseURL,\n\t\tAPIKeys: []string{apiKey},\n\t\tPriority: 1,\n\t\tStatus: \"active\",\n\t}\n\tapplyTargetDefaults(&payload, preset.ID, target, planID)\n\tif targetConfigs, ok := channelTargetConfigs[target]; ok {\n\t\tif targetConfig, ok := targetConfigs[preset.ID]; ok {\n\t\t\tif preset.ID == ProviderKimi {\n\t\t\t\ttargetConfig = applyKimiPlanOverrides(targetConfig, target, planID)\n\t\t\t}\n\t\t\tapplyChannelTargetConfig(&payload, targetConfig)\n\t\t}\n\t}\n\treturn payload, nil\n}\n\nfunc buildGitHubCopilotPayload(req CreateChannelRequest) (ChannelPayload, error) {\n\ttarget := strings.TrimSpace(req.Target)\n\tif target == \"\" {\n\t\ttarget = TargetResponses\n\t}\n\tif !isGitHubCopilotTarget(target) {\n\t\treturn ChannelPayload{}, fmt.Errorf(\"GitHub Copilot 不支持添加到 %s 渠道\", target)\n\t}\n\n\tapiKey := strings.TrimSpace(req.APIKey)\n\tif apiKey == \"\" {\n\t\treturn ChannelPayload{}, fmt.Errorf(\"API Key 不能为空\")\n\t}\n\n\tbaseURL := strings.TrimSpace(req.BaseURL)\n\tif baseURL == \"\" {\n\t\tbaseURL = \"https://api.githubcopilot.com\"\n\t}\n\tname := strings.TrimSpace(req.Name)\n\tif name == \"\" {\n\t\tname = defaultChannelName(ProviderGitHubCopilot, target)\n\t}\n\n\treturn ChannelPayload{\n\t\tName: name,\n\t\tDescription: strings.TrimSpace(req.Description),\n\t\tWebsite: \"https://github.com/settings/copilot\",\n\t\tServiceType: \"copilot\",\n\t\tBaseURL: baseURL,\n\t\tAPIKeys: []string{apiKey},\n\t\tProxyURL: strings.TrimSpace(req.ProxyURL),\n\t\tPriority: 1,\n\t\tStatus: \"active\",\n\t}, nil\n}\n\nfunc isGitHubCopilotTarget(target string) bool {\n\tswitch target {\n\tcase TargetMessages, TargetChat, TargetResponses, TargetGemini:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// targetMatchesURL 判断 URL 是否与 target 协议兼容。\n// messages target 使用 Anthropic 协议,需要 anthropic 路径;\n// chat/responses target 使用 OpenAI 协议,不应使用 anthropic 路径。\n\n// bestPlanForTarget 根据 target 自动选择最合适的 plan。\n// 当 provider 有多个 plan(如 DeepSeek 的 /anthropic 和 /v1)时,\n// 确保 chat/responses 选择 OpenAI-compatible 入口,messages 选择 Anthropic 入口。\nfunc bestPlanForTarget(preset ProviderPreset, target string) string {\n\tif len(preset.Plans) == 0 {\n\t\treturn \"\"\n\t}\n\tif len(preset.Plans) == 1 {\n\t\treturn preset.Plans[0].ID\n\t}\n\tif usesChatUpstreamForMessages(preset.ID) && target == TargetMessages {\n\t\tfor _, plan := range preset.Plans {\n\t\t\tif !plan.Custom && plan.Recommended && isChatUpstreamPlan(plan) {\n\t\t\t\treturn plan.ID\n\t\t\t}\n\t\t}\n\t\tfor _, plan := range preset.Plans {\n\t\t\tif isChatUpstreamPlan(plan) {\n\t\t\t\treturn plan.ID\n\t\t\t}\n\t\t}\n\t}\n\tif target == TargetResponses {\n\t\tfor _, plan := range preset.Plans {\n\t\t\tif !plan.Custom && plan.Protocol() == \"responses\" {\n\t\t\t\treturn plan.ID\n\t\t\t}\n\t\t}\n\t}\n\tfor _, plan := range preset.Plans {\n\t\tif !plan.Custom && planCompatibleWithTarget(plan, target) {\n\t\t\treturn plan.ID\n\t\t}\n\t}\n\tfor _, plan := range preset.Plans {\n\t\tif plan.Recommended {\n\t\t\treturn plan.ID\n\t\t}\n\t}\n\treturn preset.Plans[0].ID\n}\n\nfunc FindPreset(provider string) (ProviderPreset, bool) {\n\tprovider = strings.ToLower(strings.TrimSpace(provider))\n\tfor _, preset := range Presets() {\n\t\tif preset.ID == provider {\n\t\t\treturn preset, true\n\t\t}\n\t}\n\treturn ProviderPreset{}, false\n}\n\n// FilterPlansForTarget 按 target 协议过滤 plans,只返回兼容的入口。\n// messages 使用 Anthropic 协议,chat/responses 使用 OpenAI 协议。\n// 自定义 plan 始终保留。\nfunc FilterPlansForTarget(preset ProviderPreset, target string) []ProviderPlan {\n\tplans := preset.Plans\n\tif len(plans) <= 1 {\n\t\treturn plans\n\t}\n\tif usesChatUpstreamForMessages(preset.ID) && target == TargetMessages {\n\t\tfiltered := make([]ProviderPlan, 0, len(plans))\n\t\tfor _, plan := range plans {\n\t\t\tif plan.Custom || isChatUpstreamPlan(plan) {\n\t\t\t\tfiltered = append(filtered, plan)\n\t\t\t}\n\t\t}\n\t\tif len(filtered) > 0 {\n\t\t\treturn filtered\n\t\t}\n\t}\n\tvar filtered []ProviderPlan\n\tfor _, plan := range plans {\n\t\tif planCompatibleWithTarget(plan, target) {\n\t\t\tfiltered = append(filtered, plan)\n\t\t}\n\t}\n\tif len(filtered) == 0 {\n\t\treturn plans\n\t}\n\treturn filtered\n}\n\nfunc usesChatUpstreamForMessages(provider string) bool {\n\treturn provider == ProviderOpenCodeZen || provider == ProviderOpenCodeGo\n}\n\nfunc isChatUpstreamPlan(plan ProviderPlan) bool {\n\treturn plan.Protocol() == \"openai\"\n}\n\nfunc planCompatibleWithTarget(plan ProviderPlan, target string) bool {\n\tif plan.Custom {\n\t\treturn true\n\t}\n\n\tprotocol := plan.Protocol()\n\tswitch target {\n\tcase TargetMessages:\n\t\treturn protocol == \"anthropic\"\n\tcase TargetChat:\n\t\treturn protocol == \"openai\"\n\tcase TargetResponses:\n\t\treturn protocol == \"responses\" || protocol == \"openai\"\n\tdefault:\n\t\treturn true\n\t}\n}\n\nfunc ResolveBaseURL(preset ProviderPreset, planID string, customBaseURL string) (string, error) {\n\tcustomBaseURL = strings.TrimSpace(customBaseURL)\n\tplanID = strings.TrimSpace(planID)\n\tif planID == \"\" {\n\t\tfor _, plan := range preset.Plans {\n\t\t\tif plan.Recommended {\n\t\t\t\tplanID = plan.ID\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\tif planID == \"\" && len(preset.Plans) > 0 {\n\t\tplanID = preset.Plans[0].ID\n\t}\n\tfor _, plan := range preset.Plans {\n\t\tif plan.ID != planID {\n\t\t\tcontinue\n\t\t}\n\t\tif plan.Custom {\n\t\t\tif customBaseURL == \"\" {\n\t\t\t\treturn \"\", fmt.Errorf(\"自定义 plan 需要填写 Base URL\")\n\t\t\t}\n\t\t\treturn customBaseURL, nil\n\t\t}\n\t\tif customBaseURL != \"\" {\n\t\t\treturn customBaseURL, nil\n\t\t}\n\t\tif plan.BaseURL == \"\" {\n\t\t\treturn \"\", fmt.Errorf(\"plan %s 缺少 Base URL\", plan.ID)\n\t\t}\n\t\treturn plan.BaseURL, nil\n\t}\n\tif customBaseURL != \"\" {\n\t\treturn customBaseURL, nil\n\t}\n\treturn \"\", fmt.Errorf(\"未找到 provider %s 的 plan: %s\", preset.ID, planID)\n}\n\nfunc supportsTarget(preset ProviderPreset, target string) bool {\n\tfor _, item := range preset.Targets {\n\t\tif item.Type == target {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc defaultChannelName(provider string, target string) string {\n\treturn fmt.Sprintf(\"desktop-%s-%s\", provider, target)\n}\n\nfunc defaultChannelNameForPlan(provider string, target string, planID string) string {\n\tif provider == ProviderOpenCodeZen && strings.HasPrefix(strings.TrimSpace(planID), \"go-\") {\n\t\treturn defaultChannelName(ProviderOpenCodeGo, target)\n\t}\n\treturn defaultChannelName(provider, target)\n}\n\ntype channelTargetConfig struct {\n\tServiceType string\n\tAuthHeader string\n\tModelMapping map[string]string\n\tReasoningMapping map[string]string\n\tReasoningParamStyle string\n\tNormalizeSystemRoleToTopLevel bool\n\tNormalizeMetadataUserId *bool\n\tStripBillingHeader bool\n\tNoVision bool\n\tNoVisionModels []string\n\tVisionFallbackModel string\n\tCodexToolCompat *bool\n\tStripCodexClientTools *bool\n\tRateLimitRPM int // 主动限速默认 RPM(0=不设默认)\n}\n\nvar channelTargetConfigs = map[string]map[string]channelTargetConfig{\n\tTargetMessages: generatedClaudeMessagesTargetConfigs(),\n\tTargetChat: generatedOpenAIChatTargetConfigs(),\n\tTargetResponses: generatedCodexResponsesTargetConfigs(),\n}\n\nfunc boolRef(value bool) *bool {\n\treturn &value\n}\n\n// applyKimiPlanOverrides 根据 Kimi planID 覆盖模型映射。\n// coding plan 使用 kimi-for-coding,普通按量使用 kimi-k2.7。\nfunc applyKimiPlanOverrides(config channelTargetConfig, target string, planID string) channelTargetConfig {\n\tisCodingPlan := strings.HasPrefix(planID, \"coding-\")\n\n\tif target == TargetMessages {\n\t\tif isCodingPlan {\n\t\t\t// Coding Plan: 使用 kimi-for-coding\n\t\t\tconfig.ModelMapping = map[string]string{\n\t\t\t\t\"fable\": \"kimi-for-coding\",\n\t\t\t\t\"haiku\": \"kimi-for-coding\",\n\t\t\t\t\"opus\": \"kimi-for-coding\",\n\t\t\t\t\"sonnet\": \"kimi-for-coding\",\n\t\t\t}\n\t\t} else {\n\t\t\t// 普通按量: 使用 kimi-k2.7\n\t\t\tconfig.ModelMapping = map[string]string{\n\t\t\t\t\"fable\": \"kimi-k2.7\",\n\t\t\t\t\"haiku\": \"kimi-k2.7\",\n\t\t\t\t\"opus\": \"kimi-k2.7\",\n\t\t\t\t\"sonnet\": \"kimi-k2.7\",\n\t\t\t}\n\t\t}\n\t} else if target == TargetResponses {\n\t\tif isCodingPlan {\n\t\t\t// Coding Plan: 使用 kimi-for-coding\n\t\t\tconfig.ModelMapping = map[string]string{\n\t\t\t\t\"codex\": \"kimi-for-coding\",\n\t\t\t\t\"gpt\": \"kimi-for-coding\",\n\t\t\t}\n\t\t} else {\n\t\t\t// 普通按量: 使用 kimi-k2.7\n\t\t\tconfig.ModelMapping = map[string]string{\n\t\t\t\t\"codex\": \"kimi-k2.7\",\n\t\t\t\t\"gpt\": \"kimi-k2.7\",\n\t\t\t}\n\t\t}\n\t}\n\n\treturn config\n}\n\nfunc applyChannelTargetConfig(payload *ChannelPayload, config channelTargetConfig) {\n\tif config.ServiceType != \"\" {\n\t\tpayload.ServiceType = config.ServiceType\n\t}\n\tif config.AuthHeader != \"\" {\n\t\tpayload.AuthHeader = config.AuthHeader\n\t}\n\tpayload.ModelMapping = maps.Clone(config.ModelMapping)\n\tpayload.ReasoningMapping = maps.Clone(config.ReasoningMapping)\n\tpayload.NoVisionModels = slices.Clone(config.NoVisionModels)\n\tpayload.ReasoningParamStyle = config.ReasoningParamStyle\n\tpayload.NormalizeSystemRoleToTopLevel = payload.NormalizeSystemRoleToTopLevel || config.NormalizeSystemRoleToTopLevel\n\tif config.NormalizeMetadataUserId != nil {\n\t\tpayload.NormalizeMetadataUserId = config.NormalizeMetadataUserId\n\t}\n\tpayload.StripBillingHeader = payload.StripBillingHeader || config.StripBillingHeader\n\tpayload.NoVision = payload.NoVision || config.NoVision\n\tpayload.VisionFallbackModel = config.VisionFallbackModel\n\tif config.CodexToolCompat != nil {\n\t\tpayload.CodexToolCompat = *config.CodexToolCompat\n\t}\n\tif config.StripCodexClientTools != nil {\n\t\tpayload.StripCodexClientTools = *config.StripCodexClientTools\n\t}\n\tif config.RateLimitRPM > 0 {\n\t\tpayload.RateLimitRPM = config.RateLimitRPM\n\t}\n}\n\nfunc applyTargetDefaults(payload *ChannelPayload, provider string, target string, planID string) {\n\tswitch target {\n\tcase TargetMessages:\n\t\tpayload.ServiceType = \"claude\"\n\t\tpayload.StripThoughtSignature = true\n\t\tif provider == ProviderOpenCodeGo {\n\t\t\tpayload.AuthHeader = \"x-api-key\"\n\t\t}\n\t\tif provider == ProviderRunAPI {\n\t\t\tpayload.NormalizeMetadataUserId = boolRef(false)\n\t\t}\n\tcase TargetChat:\n\t\tpayload.ServiceType = \"openai\"\n\tcase TargetResponses:\n\t\tpayload.ServiceType = \"openai\"\n\t\tpayload.CodexToolCompat = true\n\t\tpayload.StripCodexClientTools = true\n\t\tif provider == ProviderRunAPI || provider == ProviderUnity2 {\n\t\t\tpayload.ServiceType = \"responses\"\n\t\t\tpayload.CodexToolCompat = false\n\t\t\tpayload.StripCodexClientTools = false\n\t\t}\n\t\tif provider == ProviderXFyun && planID == \"responses\" {\n\t\t\tpayload.ServiceType = \"responses\"\n\t\t}\n\t}\n\n\tconfigs, ok := channelTargetConfigs[target]\n\tif !ok {\n\t\treturn\n\t}\n\tconfig, ok := configs[provider]\n\tif !ok {\n\t\treturn\n\t}\n\n\t// Kimi 根据 planID 选择不同的模型映射\n\tif provider == ProviderKimi {\n\t\tconfig = applyKimiPlanOverrides(config, target, planID)\n\t}\n\n\tapplyChannelTargetConfig(payload, config)\n}\n"}, "e972d82e6b4b3edf23f948edbd0db2d4c80db655": {"t": 1785809885.3582084, "n": 35122, "h": "07156f086edce1d3", "c": "package channelpreset\n\nimport (\n\t\"fmt\"\n\t\"maps\"\n\t\"slices\"\n\t\"strings\"\n)\n\nconst (\n\tProviderDeepSeek = \"deepseek\"\n\tProviderMiMo = \"mimo\"\n\tProviderCompshare = \"compshare\"\n\tProviderRunAPI = \"runapi\"\n\tProviderUnity2 = \"unity2\"\n\tProviderKimi = \"kimi\"\n\tProviderGLM = \"glm\"\n\tProviderSenseNova = \"sensenova\"\n\tProviderMiniMax = \"minimax\"\n\tProviderDashScope = \"dashscope\"\n\tProviderTencentLkeap = \"tencent-lkeap\"\n\tProviderKimiCode = \"kimi-code\"\n\tProviderVolcArk = \"volc-ark\"\n\tProviderQianfan = \"qianfan\"\n\tProviderXFyun = \"xfyun\"\n\tProviderOriginRouter = \"originrouter\"\n\tProviderOpenRouter = \"openrouter\"\n\tProviderModelScope = \"modelscope\"\n\tProviderOpenCodeZen = \"opencode-zen\"\n\tProviderOpenCodeGo = \"opencode-go\"\n\tProviderGitHubCopilot = \"github-copilot\"\n\n\tTargetMessages = \"messages\"\n\tTargetChat = \"chat\"\n\tTargetResponses = \"responses\"\n\tTargetGemini = \"gemini\"\n)\n\ntype ProviderPreset struct {\n\tID string `json:\"id\"`\n\tOrder int `json:\"order\"`\n\tLabel string `json:\"label\"`\n\tDescription string `json:\"description\"`\n\tDirectAgent bool `json:\"directAgent\"`\n\tNativeMessages bool `json:\"nativeMessages\"`\n\tChatCompatible bool `json:\"chatCompatible\"`\n\tResponsesCompatible bool `json:\"responsesCompatible\"`\n\tPlans []ProviderPlan `json:\"plans\"`\n\tTargets []ChannelTarget `json:\"targets\"`\n\tDefaultTarget string `json:\"defaultTarget\"`\n}\n\ntype ProviderPlan struct {\n\tID string `json:\"id\"`\n\tLabel string `json:\"label\"`\n\tBaseURL string `json:\"baseUrl\"`\n\tDescription string `json:\"description\"`\n\tRecommended bool `json:\"recommended\"`\n\tCustom bool `json:\"custom\"`\n}\n\n// Protocol 返回 plan 所属的协议类型。\nfunc (p ProviderPlan) Protocol() string {\n\tid := strings.ToLower(p.ID)\n\tlabel := strings.ToLower(p.Label)\n\tbaseURL := strings.TrimRight(strings.ToLower(p.BaseURL), \"/\")\n\tif strings.Contains(id, \"responses\") || strings.Contains(label, \"responses\") || strings.HasSuffix(baseURL, \"/responses\") {\n\t\treturn \"responses\"\n\t}\n\tif strings.Contains(id, \"anthropic\") || strings.Contains(label, \"anthropic\") || strings.Contains(baseURL, \"anthropic\") || strings.HasSuffix(baseURL, \"/messages\") {\n\t\treturn \"anthropic\"\n\t}\n\treturn \"openai\"\n}\n\ntype ChannelTarget struct {\n\tType string `json:\"type\"`\n\tLabel string `json:\"label\"`\n\tDescription string `json:\"description\"`\n\tRecommended bool `json:\"recommended\"`\n}\n\ntype CreateChannelRequest struct {\n\tProvider string `json:\"provider\"`\n\tTarget string `json:\"target\"`\n\tPlanID string `json:\"planId\"`\n\tBaseURL string `json:\"baseUrl\"`\n\tAPIKey string `json:\"apiKey\"`\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tProxyURL string `json:\"proxyUrl\"`\n}\n\ntype CreateChannelResult struct {\n\tProvider string `json:\"provider\"`\n\tTarget string `json:\"target\"`\n\tName string `json:\"name\"`\n\tBaseURL string `json:\"baseUrl\"`\n\tMessage string `json:\"message\"`\n}\n\ntype ChannelPayload struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description,omitempty\"`\n\tWebsite string `json:\"website,omitempty\"`\n\tServiceType string `json:\"serviceType\"`\n\tAuthHeader string `json:\"authHeader,omitempty\"`\n\tBaseURL string `json:\"baseUrl\"`\n\tAPIKeys []string `json:\"apiKeys\"`\n\tProxyURL string `json:\"proxyUrl,omitempty\"`\n\tModelMapping map[string]string `json:\"modelMapping,omitempty\"`\n\tReasoningMapping map[string]string `json:\"reasoningMapping,omitempty\"`\n\tReasoningParamStyle string `json:\"reasoningParamStyle,omitempty\"`\n\tNormalizeSystemRoleToTopLevel bool `json:\"normalizeSystemRoleToTopLevel,omitempty\"`\n\tStripBillingHeader bool `json:\"stripBillingHeader,omitempty\"`\n\tNoVision bool `json:\"noVision,omitempty\"`\n\tNoVisionModels []string `json:\"noVisionModels,omitempty\"`\n\tVisionFallbackModel string `json:\"visionFallbackModel,omitempty\"`\n\tSupportedModels []string `json:\"supportedModels,omitempty\"`\n\tInjectDummyThoughtSignature bool `json:\"injectDummyThoughtSignature,omitempty\"`\n\tStripThoughtSignature bool `json:\"stripThoughtSignature,omitempty\"`\n\tCodexToolCompat bool `json:\"codexToolCompat,omitempty\"`\n\tStripCodexClientTools bool `json:\"stripCodexClientTools,omitempty\"`\n\tNormalizeMetadataUserId *bool `json:\"normalizeMetadataUserId,omitempty\"`\n\tRateLimitRPM int `json:\"rateLimitRpm,omitempty\"`\n\tRateLimitBurst int `json:\"rateLimitBurst,omitempty\"`\n\tRateLimitMaxConcurrent int `json:\"rateLimitMaxConcurrent,omitempty\"`\n\tRateLimitAutoFromHeaders bool `json:\"rateLimitAutoFromHeaders,omitempty\"`\n\tPriority int `json:\"priority,omitempty\"`\n\tStatus string `json:\"status,omitempty\"`\n}\n\nvar providerConsoleURLs = map[string]string{\n\tProviderDeepSeek: \"https://platform.deepseek.com/usage\",\n\tProviderMiMo: \"https://platform.xiaomimimo.com/console/balance\",\n\tProviderCompshare: \"https://console.compshare.cn/light-gpu/model-subscription\",\n\tProviderRunAPI: \"https://runapi.co/console\",\n\tProviderUnity2: \"https://unity2.ai/dashboard\",\n\tProviderKimi: \"https://platform.moonshot.cn/console/account\",\n\tProviderGLM: \"https://open.bigmodel.cn/coding-plan/personal/overview\",\n\tProviderSenseNova: \"https://platform.sensenova.cn/console\",\n\tProviderMiniMax: \"https://platform.minimaxi.com/user-center/payment/balance\",\n\tProviderDashScope: \"https://bailian.console.aliyun.com/cn-beijing?tab=model#/api-key\",\n\tProviderTencentLkeap: \"https://console.cloud.tencent.com/lkeap/token-plan\",\n\tProviderKimiCode: \"https://www.kimi.com/code/console\",\n\tProviderVolcArk: \"https://console.volcengine.com/ark/region:cn-beijing/subscription/coding-plan?projectName=default\",\n\tProviderQianfan: \"https://console.bce.baidu.com/qianfan/resource/subscribe\",\n\tProviderXFyun: \"https://console.xfyun.cn/\",\n\tProviderOriginRouter: \"https://easytransnote.com/ai/console/#key\",\n\tProviderOpenRouter: \"https://openrouter.ai/keys\",\n\tProviderModelScope: \"https://modelscope.cn/my/myaccesstoken\",\n\tProviderOpenCodeZen: \"https://opencode.ai/\",\n\tProviderOpenCodeGo: \"https://opencode.ai/\",\n}\n\n// defaultTargets 返回标准的三目标配置(Messages、Responses、Chat),Messages 默认推荐。\nfunc defaultTargets() []ChannelTarget {\n\treturn []ChannelTarget{\n\t\t{Type: TargetMessages, Label: \"Claude Messages\", Description: \"Claude native Messages protocol, supports Claude Code direct or via CCX proxy\", Recommended: true},\n\t\t{Type: TargetResponses, Label: \"Codex Responses\", Description: \"OpenAI Responses protocol, for Codex CLI and compatible clients\"},\n\t\t{Type: TargetChat, Label: \"OpenAI Chat\", Description: \"OpenAI Chat Completions protocol, compatible with various Chat clients and third-party tools\"},\n\t}\n}\n\n// newFullCapabilityPreset 创建一个具有完整能力的 Preset 模板(全协议支持、全目标、默认 Messages)。\n// 调用者只需设置 ID、Order、Label、Description 和 Plans。\nfunc newFullCapabilityPreset(id, label, description string, order int, plans []ProviderPlan) ProviderPreset {\n\treturn ProviderPreset{\n\t\tID: id,\n\t\tOrder: order,\n\t\tLabel: label,\n\t\tDescription: description,\n\t\tDirectAgent: true,\n\t\tNativeMessages: true,\n\t\tChatCompatible: true,\n\t\tResponsesCompatible: true,\n\t\tPlans: plans,\n\t\tTargets: defaultTargets(),\n\t\tDefaultTarget: TargetMessages,\n\t}\n}\n\n// dualProtocolPlans 生成标准的双协议 Plans(Anthropic + OpenAI)。\n// anthropicURL 是 Anthropic 协议的完整 URL。\n// openaiURL 是 OpenAI 协议的完整 URL。\nfunc dualProtocolPlans(anthropicURL, openaiURL string) []ProviderPlan {\n\treturn []ProviderPlan{\n\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: anthropicURL, Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: openaiURL, Description: \"Chat / Responses 通用入口\"},\n\t}\n}\n\n// dualProtocolPlansSimple 生成标准的双协议 Plans,anthropic 和 openai 使用相同的 baseURL。\n// 适用于 RunAPI、Unity2 等统一入口的服务商。\nfunc dualProtocolPlansSimple(baseURL string) []ProviderPlan {\n\treturn dualProtocolPlans(baseURL, baseURL)\n}\n\nfunc Presets() []ProviderPreset {\n\treturn []ProviderPreset{\n\t\tnewFullCapabilityPreset(\n\t\t\tProviderDeepSeek,\n\t\t\t\"DeepSeek\",\n\t\t\t\"国产高性能推理模型,V4-Pro / V4-Flash 双旗舰,以极低成本实现顶级推理能力,完全开源(MIT),性价比极高。\",\n\t\t\t10,\n\t\t\tdualProtocolPlans(\"https://api.deepseek.com/anthropic\", \"https://api.deepseek.com/v1\"),\n\t\t),\n\t\t{\n\t\t\tID: ProviderMiMo,\n\t\t\tOrder: 20,\n\t\t\tLabel: \"MiMo\",\n\t\t\tDescription: \"小米自研大模型,面向 Agent 时代,旗舰 MiMo-V2.5-Pro 万亿参数、支持百万上下文,深度赋能人车家全生态。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"按量 (Anthropic)\", BaseURL: \"https://api.xiaomimimo.com/anthropic\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"按量 (OpenAI)\", BaseURL: \"https://api.xiaomimimo.com/v1\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t\t{ID: \"token-cn\", Label: \"Token Plan - 中国 (OpenAI)\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Description: \"中国区 Token Plan Chat / Responses 通用入口\"},\n\t\t\t\t{ID: \"token-sgp\", Label: \"Token Plan - 新加坡 (OpenAI)\", BaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\", Description: \"新加坡区 Token Plan Chat / Responses 通用入口\"},\n\t\t\t\t{ID: \"token-ams\", Label: \"Token Plan - 欧洲 (OpenAI)\", BaseURL: \"https://token-plan-ams.xiaomimimo.com/v1\", Description: \"欧洲区 Token Plan Chat / Responses 通用入口\"},\n\t\t\t\t{ID: \"token-cn-anthropic\", Label: \"Token Plan - 中国 (Anthropic)\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/anthropic\", Description: \"中国区 Token Plan Claude Messages 原生入口\"},\n\t\t\t\t{ID: \"token-sgp-anthropic\", Label: \"Token Plan - 新加坡 (Anthropic)\", BaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\", Description: \"新加坡区 Token Plan Claude Messages 原生入口\"},\n\t\t\t\t{ID: \"token-ams-anthropic\", Label: \"Token Plan - 欧洲 (Anthropic)\", BaseURL: \"https://token-plan-ams.xiaomimimo.com/anthropic\", Description: \"欧洲区 Token Plan Claude Messages 原生入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\tnewFullCapabilityPreset(\n\t\t\tProviderCompshare,\n\t\t\t\"优云智算套餐\",\n\t\t\t\"优云智算是 UCloud 旗下 AI 云平台,提供高性价比国内 AI 模型 Agent Plan 套餐,支持包月订阅或按量付费(49 元/月起),同时提供官方海外模型稳定接入,支持 Claude Code、Codex 与 API 集成,具备企业级高并发、7×24 技术支持和自助开票能力;通过推广链接注册可领取 5 元平台试用金。\",\n\t\t\t30,\n\t\t\tdualProtocolPlans(\"https://cp.compshare.cn\", \"https://cp.compshare.cn/v1\"),\n\t\t),\n\t\tnewFullCapabilityPreset(\n\t\t\tProviderRunAPI,\n\t\t\t\"RunAPI\",\n\t\t\t\"RunAPI 是高效稳定的API OpenRouter平替平台,一个 API Key 即可访问 OpenAI、Claude、Gemini、DeepSeek、Grok 等 150+ 主流模型,低至 1 折,极其稳定,可以无缝兼容 Claude Code、OpenClaw 等工具。RunAPI 为 CCX用户提供专属福利:注册联系管理员即可领取¥7的免费额度\",\n\t\t\t40,\n\t\t\tdualProtocolPlansSimple(\"https://runapi.co/v1\"),\n\t\t),\n\t\tnewFullCapabilityPreset(\n\t\t\tProviderUnity2,\n\t\t\t\"Unity2.ai\",\n\t\t\t\"Unity2.ai 是面向个人开发者、团队、企业的高性能 AI 模型 API 中转平台,长期服务国内头部企业,日均承载超 300 亿 token 调用,支持 5000 RPM 级高并发。一个 API Key 即可适配 Claude Code、Codex、OpenAI 模型、IDE 插件和 Agent 工作流等场景。具备企业级稳定供应能力,在高并发、持续调用和团队集中采购场景下依然保持低延迟、高可用。现在注册 Unity2.ai 可领取 $2 余额,加入官方群再送 $10 余额,合计最高可领 $12 免费额度。\",\n\t\t\t45,\n\t\t\tdualProtocolPlansSimple(\"https://unity2.ai/v1\"),\n\t\t),\n\t\t{\n\t\t\tID: ProviderKimi,\n\t\t\tOrder: 50,\n\t\t\tLabel: \"Kimi / Moonshot\",\n\t\t\tDescription: \"月之暗面旗下智能助手平台,旗舰 Kimi K2.6 原生多模态,以超长上下文和 Agent 集群能力著称,深度研究表现突出。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"按量 (Anthropic)\", BaseURL: \"https://api.moonshot.cn/anthropic\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"按量 (OpenAI)\", BaseURL: \"https://api.moonshot.cn/v1\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t\t{ID: \"coding-anthropic\", Label: \"Coding Plan (Anthropic)\", BaseURL: \"https://api.kimi.com/coding\", Description: \"Coding Plan Claude Messages 原生入口\"},\n\t\t\t\t{ID: \"coding-openai-chat\", Label: \"Coding Plan (OpenAI)\", BaseURL: \"https://api.kimi.com/coding/v1\", Description: \"Coding Plan Chat / Responses 通用入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderGLM,\n\t\t\tOrder: 60,\n\t\t\tLabel: \"GLM / BigModel\",\n\t\t\tDescription: \"智谱 AI 大模型开放平台,旗舰 GLM-5.1 编程能力对标前沿闭源模型,港股上市公司,全程华为昇腾训练,性价比突出。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://open.bigmodel.cn/api/anthropic\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"coding\", Label: \"Coding Plan (OpenAI)\", BaseURL: \"https://open.bigmodel.cn/api/coding/paas/v4#\", Description: \"Coding Plan Chat / Responses 通用入口\"},\n\t\t\t\t{ID: \"openai-chat\", Label: \"通用 (OpenAI)\", BaseURL: \"https://open.bigmodel.cn/api/paas/v4#\", Description: \"通用 Chat / Responses 入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderSenseNova,\n\t\t\tOrder: 65,\n\t\t\tLabel: \"SenseNova\",\n\t\t\tDescription: \"商汤日日新 SenseNova LLM API 平台,兼容 OpenAI 与 Anthropic 协议,支持轻量多模态模型和主流编程模型接入。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://token.sensenova.cn\", Description: \"Claude Messages 兼容入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://token.sensenova.cn/v1\", Description: \"Chat 入口,Responses 由 CCX 转换\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderMiniMax,\n\t\t\tOrder: 70,\n\t\t\tLabel: \"MiniMax\",\n\t\t\tDescription: \"全球化多模态 AI 公司(港股上市),旗舰 M3 支持百万上下文、原生视觉与 Agent 能力,海螺视频 / 音频模型全球第一梯队,海外收入超七成。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://api.minimaxi.com/anthropic\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://api.minimax.chat/v1\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderDashScope,\n\t\t\tOrder: 80,\n\t\t\tLabel: \"阿里云 DashScope\",\n\t\t\tDescription: \"阿里云一站式大模型服务平台,集成千问全系列及 DeepSeek 等主流模型,覆盖文本、图像、音视频全模态,提供按量、Coding Plan、Token Plan 多种计费。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"按量 (Anthropic)\", BaseURL: \"https://dashscope.aliyuncs.com/apps/anthropic\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"按量 (OpenAI)\", BaseURL: \"https://dashscope.aliyuncs.com/compatible-mode/v1\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t\t{ID: \"coding-anthropic\", Label: \"Coding Plan (Anthropic)\", BaseURL: \"https://coding.dashscope.aliyuncs.com/apps/anthropic\", Description: \"Coding Plan Claude Messages 原生入口\"},\n\t\t\t\t{ID: \"coding-openai-chat\", Label: \"Coding Plan (OpenAI)\", BaseURL: \"https://coding.dashscope.aliyuncs.com/v1\", Description: \"Coding Plan Chat / Responses 通用入口\"},\n\t\t\t\t{ID: \"token-plan-anthropic\", Label: \"Token Plan (Anthropic)\", BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic\", Description: \"Token Plan Claude Messages 原生入口\"},\n\t\t\t\t{ID: \"token-plan-openai-chat\", Label: \"Token Plan (OpenAI)\", BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1\", Description: \"Token Plan Chat / Responses 通用入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderOpenCodeZen,\n\t\t\tOrder: 90,\n\t\t\tLabel: \"OpenCode Zen / Go\",\n\t\t\tDescription: \"OpenCode 官方精选 AI 编程模型网关,按量 Zen 与低成本 Go 订阅共用同一套模型预设。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"go-openai-chat\", Label: \"Go (OpenAI-compatible)\", BaseURL: \"https://opencode.ai/zen/go/v1\", Description: \"订阅套餐入口,Messages 经 CCX 转 Chat 上游\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"Zen (OpenAI-compatible)\", BaseURL: \"https://opencode.ai/zen/v1\", Description: \"按量付费入口,Messages 经 CCX 转 Chat 上游\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderOpenCodeGo,\n\t\t\tOrder: 100,\n\t\t\tLabel: \"OpenCode Go\",\n\t\t\tDescription: \"OpenCode 低成本开源编程模型订阅服务($5/月起),深度优化编程 Agent 场景。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://opencode.ai/zen/go/v1\", Description: \"Claude Messages 原生入口\"},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://opencode.ai/zen/go/v1\", Description: \"Messages 经 CCX 转 Chat 上游,Chat / Responses 直连该入口\", Recommended: true},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderTencentLkeap,\n\t\t\tOrder: 85,\n\t\t\tLabel: \"腾讯云 TokenHub\",\n\t\t\tDescription: \"腾讯云大模型服务平台,整合混元及 DeepSeek、Kimi 等主流模型,提供 Token Plan 订阅和按量计费,适配 Claude Code、Codex 等主流编程工具。\",\n\t\t\tDirectAgent: false,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/anthropic\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/v3\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderVolcArk,\n\t\t\tOrder: 86,\n\t\t\tLabel: \"火山方舟 Coding Plan\",\n\t\t\tDescription: \"字节跳动火山引擎旗下一站式 AI 平台,提供豆包系列模型精调、推理、评测全链路服务,覆盖多厂商模型,企业级保障完善。\",\n\t\t\tDirectAgent: false,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://ark.cn-beijing.volces.com/api/coding\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderQianfan,\n\t\t\tOrder: 87,\n\t\t\tLabel: \"百度千帆 Coding Plan\",\n\t\t\tDescription: \"百度智能云企业级大模型平台,内置文心 5.0 原生全模态大模型,支持多智能体协同,提供从开发到部署的全链路工具。\",\n\t\t\tDirectAgent: false,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://qianfan.baidubce.com/anthropic/coding\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://qianfan.baidubce.com/v2/coding#\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderXFyun,\n\t\t\tOrder: 88,\n\t\t\tLabel: \"讯飞星辰\",\n\t\t\tDescription: \"科大讯飞星辰 MaaS Astron Coding Plan,提供 OpenAI、Anthropic 与 Responses 三个独立 Coding Plan 入口。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/anthropic\", Description: \"Claude Messages Coding Plan 入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v2\", Description: \"OpenAI Chat Coding Plan 入口\"},\n\t\t\t\t{ID: \"responses\", Label: \"Responses-compatible\", BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v1/responses\", Description: \"OpenAI Responses Coding Plan 入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderOpenRouter,\n\t\t\tOrder: 35,\n\t\t\tLabel: \"OpenRouter\",\n\t\t\tDescription: \"全球最大的 AI 模型聚合平台,一个 API 密钥访问数百个模型,兼容 OpenAI 接口,提供安全治理和预算管控能力。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://openrouter.ai/api\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://openrouter.ai/api/v1\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderModelScope,\n\t\t\tOrder: 82,\n\t\t\tLabel: \"ModelScope 魔搭\",\n\t\t\tDescription: \"阿里达摩院发起的开源模型社区,汇聚上万个模型,覆盖文本、图像、语音等全模态,支持模型探索、推理与训练全流程。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://api-inference.modelscope.cn\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://api-inference.modelscope.cn/v1\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\t// 极易云仅在渠道中心可用,刻意不纳入 Agent(Claude/Codex/OpenCode)直连下拉。\n\t\t\t// 即便三个协议标志均为 true,也不要将其加入 desktop/internal/configservice 的直连 provider 列表。\n\t\t\tID: ProviderOriginRouter,\n\t\t\tOrder: 200,\n\t\t\tLabel: \"极易云 OriginRouter\",\n\t\t\tDescription: \"极易云统一模型 API 转发平台,一个密钥无缝切换 GPT、Claude、Gemini 等主流模型,高速稳定,适合多模型混用场景。\",\n\t\t\tDirectAgent: false,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://api.easytransnote.com/coding\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://api.easytransnote.com/coding/v1\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t}\n}\n\nfunc BuildPayload(req CreateChannelRequest) (ChannelPayload, error) {\n\tif strings.EqualFold(strings.TrimSpace(req.Provider), ProviderGitHubCopilot) {\n\t\treturn buildGitHubCopilotPayload(req)\n\t}\n\n\tpreset, ok := FindPreset(req.Provider)\n\tif !ok {\n\t\treturn ChannelPayload{}, fmt.Errorf(\"不支持的 provider: %s\", req.Provider)\n\t}\n\ttarget := strings.TrimSpace(req.Target)\n\tif target == \"\" {\n\t\ttarget = preset.DefaultTarget\n\t}\n\tif !supportsTarget(preset, target) {\n\t\treturn ChannelPayload{}, fmt.Errorf(\"%s 不支持添加到 %s 渠道\", preset.Label, target)\n\t}\n\tapiKey := strings.TrimSpace(req.APIKey)\n\tif apiKey == \"\" {\n\t\treturn ChannelPayload{}, fmt.Errorf(\"API Key 不能为空\")\n\t}\n\tplanID := strings.TrimSpace(req.PlanID)\n\tif planID == \"\" {\n\t\tplanID = bestPlanForTarget(preset, target)\n\t}\n\tbaseURL, err := ResolveBaseURL(preset, planID, req.BaseURL)\n\tif err != nil {\n\t\treturn ChannelPayload{}, err\n\t}\n\tname := strings.TrimSpace(req.Name)\n\tif name == \"\" {\n\t\tname = defaultChannelNameForPlan(preset.ID, target, planID)\n\t}\n\tpayload := ChannelPayload{\n\t\tName: name,\n\t\tDescription: strings.TrimSpace(req.Description),\n\t\tWebsite: providerConsoleURLs[preset.ID],\n\t\tBaseURL: baseURL,\n\t\tAPIKeys: []string{apiKey},\n\t\tPriority: 1,\n\t\tStatus: \"active\",\n\t}\n\tapplyTargetDefaults(&payload, preset.ID, target, planID)\n\tif targetConfigs, ok := channelTargetConfigs[target]; ok {\n\t\tif targetConfig, ok := targetConfigs[preset.ID]; ok {\n\t\t\tif preset.ID == ProviderKimi {\n\t\t\t\ttargetConfig = applyKimiPlanOverrides(targetConfig, target, planID)\n\t\t\t}\n\t\t\tapplyChannelTargetConfig(&payload, targetConfig)\n\t\t}\n\t}\n\treturn payload, nil\n}\n\nfunc buildGitHubCopilotPayload(req CreateChannelRequest) (ChannelPayload, error) {\n\ttarget := strings.TrimSpace(req.Target)\n\tif target == \"\" {\n\t\ttarget = TargetResponses\n\t}\n\tif !isGitHubCopilotTarget(target) {\n\t\treturn ChannelPayload{}, fmt.Errorf(\"GitHub Copilot 不支持添加到 %s 渠道\", target)\n\t}\n\n\tapiKey := strings.TrimSpace(req.APIKey)\n\tif apiKey == \"\" {\n\t\treturn ChannelPayload{}, fmt.Errorf(\"API Key 不能为空\")\n\t}\n\n\tbaseURL := strings.TrimSpace(req.BaseURL)\n\tif baseURL == \"\" {\n\t\tbaseURL = \"https://api.githubcopilot.com\"\n\t}\n\tname := strings.TrimSpace(req.Name)\n\tif name == \"\" {\n\t\tname = defaultChannelName(ProviderGitHubCopilot, target)\n\t}\n\n\treturn ChannelPayload{\n\t\tName: name,\n\t\tDescription: strings.TrimSpace(req.Description),\n\t\tWebsite: \"https://github.com/settings/copilot\",\n\t\tServiceType: \"copilot\",\n\t\tBaseURL: baseURL,\n\t\tAPIKeys: []string{apiKey},\n\t\tProxyURL: strings.TrimSpace(req.ProxyURL),\n\t\tPriority: 1,\n\t\tStatus: \"active\",\n\t}, nil\n}\n\nfunc isGitHubCopilotTarget(target string) bool {\n\tswitch target {\n\tcase TargetMessages, TargetChat, TargetResponses, TargetGemini:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// targetMatchesURL 判断 URL 是否与 target 协议兼容。\n// messages target 使用 Anthropic 协议,需要 anthropic 路径;\n// chat/responses target 使用 OpenAI 协议,不应使用 anthropic 路径。\n\n// bestPlanForTarget 根据 target 自动选择最合适的 plan。\n// 当 provider 有多个 plan(如 DeepSeek 的 /anthropic 和 /v1)时,\n// 确保 chat/responses 选择 OpenAI-compatible 入口,messages 选择 Anthropic 入口。\nfunc bestPlanForTarget(preset ProviderPreset, target string) string {\n\tif len(preset.Plans) == 0 {\n\t\treturn \"\"\n\t}\n\tif len(preset.Plans) == 1 {\n\t\treturn preset.Plans[0].ID\n\t}\n\tif usesChatUpstreamForMessages(preset.ID) && target == TargetMessages {\n\t\tfor _, plan := range preset.Plans {\n\t\t\tif !plan.Custom && plan.Recommended && isChatUpstreamPlan(plan) {\n\t\t\t\treturn plan.ID\n\t\t\t}\n\t\t}\n\t\tfor _, plan := range preset.Plans {\n\t\t\tif isChatUpstreamPlan(plan) {\n\t\t\t\treturn plan.ID\n\t\t\t}\n\t\t}\n\t}\n\tif target == TargetResponses {\n\t\tfor _, plan := range preset.Plans {\n\t\t\tif !plan.Custom && plan.Protocol() == \"responses\" {\n\t\t\t\treturn plan.ID\n\t\t\t}\n\t\t}\n\t}\n\tfor _, plan := range preset.Plans {\n\t\tif !plan.Custom && planCompatibleWithTarget(plan, target) {\n\t\t\treturn plan.ID\n\t\t}\n\t}\n\tfor _, plan := range preset.Plans {\n\t\tif plan.Recommended {\n\t\t\treturn plan.ID\n\t\t}\n\t}\n\treturn preset.Plans[0].ID\n}\n\nfunc FindPreset(provider string) (ProviderPreset, bool) {\n\tprovider = strings.ToLower(strings.TrimSpace(provider))\n\tfor _, preset := range Presets() {\n\t\tif preset.ID == provider {\n\t\t\treturn preset, true\n\t\t}\n\t}\n\treturn ProviderPreset{}, false\n}\n\n// FilterPlansForTarget 按 target 协议过滤 plans,只返回兼容的入口。\n// messages 使用 Anthropic 协议,chat/responses 使用 OpenAI 协议。\n// 自定义 plan 始终保留。\nfunc FilterPlansForTarget(preset ProviderPreset, target string) []ProviderPlan {\n\tplans := preset.Plans\n\tif len(plans) <= 1 {\n\t\treturn plans\n\t}\n\tif usesChatUpstreamForMessages(preset.ID) && target == TargetMessages {\n\t\tfiltered := make([]ProviderPlan, 0, len(plans))\n\t\tfor _, plan := range plans {\n\t\t\tif plan.Custom || isChatUpstreamPlan(plan) {\n\t\t\t\tfiltered = append(filtered, plan)\n\t\t\t}\n\t\t}\n\t\tif len(filtered) > 0 {\n\t\t\treturn filtered\n\t\t}\n\t}\n\tvar filtered []ProviderPlan\n\tfor _, plan := range plans {\n\t\tif planCompatibleWithTarget(plan, target) {\n\t\t\tfiltered = append(filtered, plan)\n\t\t}\n\t}\n\tif len(filtered) == 0 {\n\t\treturn plans\n\t}\n\treturn filtered\n}\n\nfunc usesChatUpstreamForMessages(provider string) bool {\n\treturn provider == ProviderOpenCodeZen || provider == ProviderOpenCodeGo\n}\n\nfunc isChatUpstreamPlan(plan ProviderPlan) bool {\n\treturn plan.Protocol() == \"openai\"\n}\n\nfunc planCompatibleWithTarget(plan ProviderPlan, target string) bool {\n\tif plan.Custom {\n\t\treturn true\n\t}\n\n\tprotocol := plan.Protocol()\n\tswitch target {\n\tcase TargetMessages:\n\t\treturn protocol == \"anthropic\"\n\tcase TargetChat:\n\t\treturn protocol == \"openai\"\n\tcase TargetResponses:\n\t\treturn protocol == \"responses\" || protocol == \"openai\"\n\tdefault:\n\t\treturn true\n\t}\n}\n\nfunc ResolveBaseURL(preset ProviderPreset, planID string, customBaseURL string) (string, error) {\n\tcustomBaseURL = strings.TrimSpace(customBaseURL)\n\tplanID = strings.TrimSpace(planID)\n\tif planID == \"\" {\n\t\tfor _, plan := range preset.Plans {\n\t\t\tif plan.Recommended {\n\t\t\t\tplanID = plan.ID\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\tif planID == \"\" && len(preset.Plans) > 0 {\n\t\tplanID = preset.Plans[0].ID\n\t}\n\tfor _, plan := range preset.Plans {\n\t\tif plan.ID != planID {\n\t\t\tcontinue\n\t\t}\n\t\tif plan.Custom {\n\t\t\tif customBaseURL == \"\" {\n\t\t\t\treturn \"\", fmt.Errorf(\"自定义 plan 需要填写 Base URL\")\n\t\t\t}\n\t\t\treturn customBaseURL, nil\n\t\t}\n\t\tif customBaseURL != \"\" {\n\t\t\treturn customBaseURL, nil\n\t\t}\n\t\tif plan.BaseURL == \"\" {\n\t\t\treturn \"\", fmt.Errorf(\"plan %s 缺少 Base URL\", plan.ID)\n\t\t}\n\t\treturn plan.BaseURL, nil\n\t}\n\tif customBaseURL != \"\" {\n\t\treturn customBaseURL, nil\n\t}\n\treturn \"\", fmt.Errorf(\"未找到 provider %s 的 plan: %s\", preset.ID, planID)\n}\n\nfunc supportsTarget(preset ProviderPreset, target string) bool {\n\tfor _, item := range preset.Targets {\n\t\tif item.Type == target {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc defaultChannelName(provider string, target string) string {\n\treturn fmt.Sprintf(\"desktop-%s-%s\", provider, target)\n}\n\nfunc defaultChannelNameForPlan(provider string, target string, planID string) string {\n\tif provider == ProviderOpenCodeZen && strings.HasPrefix(strings.TrimSpace(planID), \"go-\") {\n\t\treturn defaultChannelName(ProviderOpenCodeGo, target)\n\t}\n\treturn defaultChannelName(provider, target)\n}\n\ntype channelTargetConfig struct {\n\tServiceType string\n\tAuthHeader string\n\tModelMapping map[string]string\n\tReasoningMapping map[string]string\n\tReasoningParamStyle string\n\tNormalizeSystemRoleToTopLevel bool\n\tNormalizeMetadataUserId *bool\n\tStripBillingHeader bool\n\tNoVision bool\n\tNoVisionModels []string\n\tVisionFallbackModel string\n\tCodexToolCompat *bool\n\tStripCodexClientTools *bool\n\tRateLimitRPM int // 主动限速默认 RPM(0=不设默认)\n}\n\nvar channelTargetConfigs = map[string]map[string]channelTargetConfig{\n\tTargetMessages: generatedClaudeMessagesTargetConfigs(),\n\tTargetChat: generatedOpenAIChatTargetConfigs(),\n\tTargetResponses: generatedCodexResponsesTargetConfigs(),\n}\n\nfunc boolRef(value bool) *bool {\n\treturn &value\n}\n\n// applyKimiPlanOverrides 根据 Kimi planID 覆盖模型映射。\n// coding plan 使用 kimi-for-coding,普通按量使用 kimi-k2.7。\nfunc applyKimiPlanOverrides(config channelTargetConfig, target string, planID string) channelTargetConfig {\n\tisCodingPlan := strings.HasPrefix(planID, \"coding-\")\n\n\tif target == TargetMessages {\n\t\tif isCodingPlan {\n\t\t\t// Coding Plan: 使用 kimi-for-coding\n\t\t\tconfig.ModelMapping = map[string]string{\n\t\t\t\t\"fable\": \"kimi-for-coding\",\n\t\t\t\t\"haiku\": \"kimi-for-coding\",\n\t\t\t\t\"opus\": \"kimi-for-coding\",\n\t\t\t\t\"sonnet\": \"kimi-for-coding\",\n\t\t\t}\n\t\t} else {\n\t\t\t// 普通按量: 使用 kimi-k2.7\n\t\t\tconfig.ModelMapping = map[string]string{\n\t\t\t\t\"fable\": \"kimi-k2.7\",\n\t\t\t\t\"haiku\": \"kimi-k2.7\",\n\t\t\t\t\"opus\": \"kimi-k2.7\",\n\t\t\t\t\"sonnet\": \"kimi-k2.7\",\n\t\t\t}\n\t\t}\n\t} else if target == TargetResponses {\n\t\tif isCodingPlan {\n\t\t\t// Coding Plan: 使用 kimi-for-coding\n\t\t\tconfig.ModelMapping = map[string]string{\n\t\t\t\t\"codex\": \"kimi-for-coding\",\n\t\t\t\t\"gpt\": \"kimi-for-coding\",\n\t\t\t}\n\t\t} else {\n\t\t\t// 普通按量: 使用 kimi-k2.7\n\t\t\tconfig.ModelMapping = map[string]string{\n\t\t\t\t\"codex\": \"kimi-k2.7\",\n\t\t\t\t\"gpt\": \"kimi-k2.7\",\n\t\t\t}\n\t\t}\n\t}\n\n\treturn config\n}\n\nfunc applyChannelTargetConfig(payload *ChannelPayload, config channelTargetConfig) {\n\tif config.ServiceType != \"\" {\n\t\tpayload.ServiceType = config.ServiceType\n\t}\n\tif config.AuthHeader != \"\" {\n\t\tpayload.AuthHeader = config.AuthHeader\n\t}\n\tpayload.ModelMapping = maps.Clone(config.ModelMapping)\n\tpayload.ReasoningMapping = maps.Clone(config.ReasoningMapping)\n\tpayload.NoVisionModels = slices.Clone(config.NoVisionModels)\n\tpayload.ReasoningParamStyle = config.ReasoningParamStyle\n\tpayload.NormalizeSystemRoleToTopLevel = payload.NormalizeSystemRoleToTopLevel || config.NormalizeSystemRoleToTopLevel\n\tif config.NormalizeMetadataUserId != nil {\n\t\tpayload.NormalizeMetadataUserId = config.NormalizeMetadataUserId\n\t}\n\tpayload.StripBillingHeader = payload.StripBillingHeader || config.StripBillingHeader\n\tpayload.NoVision = payload.NoVision || config.NoVision\n\tpayload.VisionFallbackModel = config.VisionFallbackModel\n\tif config.CodexToolCompat != nil {\n\t\tpayload.CodexToolCompat = *config.CodexToolCompat\n\t}\n\tif config.StripCodexClientTools != nil {\n\t\tpayload.StripCodexClientTools = *config.StripCodexClientTools\n\t}\n\tif config.RateLimitRPM > 0 {\n\t\tpayload.RateLimitRPM = config.RateLimitRPM\n\t}\n}\n\nfunc applyTargetDefaults(payload *ChannelPayload, provider string, target string, planID string) {\n\tswitch target {\n\tcase TargetMessages:\n\t\tpayload.ServiceType = \"claude\"\n\t\tpayload.StripThoughtSignature = true\n\t\tif provider == ProviderOpenCodeGo {\n\t\t\tpayload.AuthHeader = \"x-api-key\"\n\t\t}\n\t\tif provider == ProviderRunAPI {\n\t\t\tpayload.NormalizeMetadataUserId = boolRef(false)\n\t\t}\n\tcase TargetChat:\n\t\tpayload.ServiceType = \"openai\"\n\tcase TargetResponses:\n\t\tpayload.ServiceType = \"openai\"\n\t\tpayload.CodexToolCompat = true\n\t\tpayload.StripCodexClientTools = true\n\t\tif provider == ProviderRunAPI || provider == ProviderUnity2 {\n\t\t\tpayload.ServiceType = \"responses\"\n\t\t\tpayload.CodexToolCompat = false\n\t\t\tpayload.StripCodexClientTools = false\n\t\t}\n\t\tif provider == ProviderXFyun && planID == \"responses\" {\n\t\t\tpayload.ServiceType = \"responses\"\n\t\t}\n\t}\n\n\tconfigs, ok := channelTargetConfigs[target]\n\tif !ok {\n\t\treturn\n\t}\n\tconfig, ok := configs[provider]\n\tif !ok {\n\t\treturn\n\t}\n\n\t// Kimi 根据 planID 选择不同的模型映射\n\tif provider == ProviderKimi {\n\t\tconfig = applyKimiPlanOverrides(config, target, planID)\n\t}\n\n\tapplyChannelTargetConfig(payload, config)\n}\n"}, "4da6d77e15a5c2ac4bc15ce1ad1a68cfdbd2e2d0": {"t": 1785846961.1369023, "n": 35122, "h": "07156f086edce1d3", "c": "package channelpreset\n\nimport (\n\t\"fmt\"\n\t\"maps\"\n\t\"slices\"\n\t\"strings\"\n)\n\nconst (\n\tProviderDeepSeek = \"deepseek\"\n\tProviderMiMo = \"mimo\"\n\tProviderCompshare = \"compshare\"\n\tProviderRunAPI = \"runapi\"\n\tProviderUnity2 = \"unity2\"\n\tProviderKimi = \"kimi\"\n\tProviderGLM = \"glm\"\n\tProviderSenseNova = \"sensenova\"\n\tProviderMiniMax = \"minimax\"\n\tProviderDashScope = \"dashscope\"\n\tProviderTencentLkeap = \"tencent-lkeap\"\n\tProviderKimiCode = \"kimi-code\"\n\tProviderVolcArk = \"volc-ark\"\n\tProviderQianfan = \"qianfan\"\n\tProviderXFyun = \"xfyun\"\n\tProviderOriginRouter = \"originrouter\"\n\tProviderOpenRouter = \"openrouter\"\n\tProviderModelScope = \"modelscope\"\n\tProviderOpenCodeZen = \"opencode-zen\"\n\tProviderOpenCodeGo = \"opencode-go\"\n\tProviderGitHubCopilot = \"github-copilot\"\n\n\tTargetMessages = \"messages\"\n\tTargetChat = \"chat\"\n\tTargetResponses = \"responses\"\n\tTargetGemini = \"gemini\"\n)\n\ntype ProviderPreset struct {\n\tID string `json:\"id\"`\n\tOrder int `json:\"order\"`\n\tLabel string `json:\"label\"`\n\tDescription string `json:\"description\"`\n\tDirectAgent bool `json:\"directAgent\"`\n\tNativeMessages bool `json:\"nativeMessages\"`\n\tChatCompatible bool `json:\"chatCompatible\"`\n\tResponsesCompatible bool `json:\"responsesCompatible\"`\n\tPlans []ProviderPlan `json:\"plans\"`\n\tTargets []ChannelTarget `json:\"targets\"`\n\tDefaultTarget string `json:\"defaultTarget\"`\n}\n\ntype ProviderPlan struct {\n\tID string `json:\"id\"`\n\tLabel string `json:\"label\"`\n\tBaseURL string `json:\"baseUrl\"`\n\tDescription string `json:\"description\"`\n\tRecommended bool `json:\"recommended\"`\n\tCustom bool `json:\"custom\"`\n}\n\n// Protocol 返回 plan 所属的协议类型。\nfunc (p ProviderPlan) Protocol() string {\n\tid := strings.ToLower(p.ID)\n\tlabel := strings.ToLower(p.Label)\n\tbaseURL := strings.TrimRight(strings.ToLower(p.BaseURL), \"/\")\n\tif strings.Contains(id, \"responses\") || strings.Contains(label, \"responses\") || strings.HasSuffix(baseURL, \"/responses\") {\n\t\treturn \"responses\"\n\t}\n\tif strings.Contains(id, \"anthropic\") || strings.Contains(label, \"anthropic\") || strings.Contains(baseURL, \"anthropic\") || strings.HasSuffix(baseURL, \"/messages\") {\n\t\treturn \"anthropic\"\n\t}\n\treturn \"openai\"\n}\n\ntype ChannelTarget struct {\n\tType string `json:\"type\"`\n\tLabel string `json:\"label\"`\n\tDescription string `json:\"description\"`\n\tRecommended bool `json:\"recommended\"`\n}\n\ntype CreateChannelRequest struct {\n\tProvider string `json:\"provider\"`\n\tTarget string `json:\"target\"`\n\tPlanID string `json:\"planId\"`\n\tBaseURL string `json:\"baseUrl\"`\n\tAPIKey string `json:\"apiKey\"`\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tProxyURL string `json:\"proxyUrl\"`\n}\n\ntype CreateChannelResult struct {\n\tProvider string `json:\"provider\"`\n\tTarget string `json:\"target\"`\n\tName string `json:\"name\"`\n\tBaseURL string `json:\"baseUrl\"`\n\tMessage string `json:\"message\"`\n}\n\ntype ChannelPayload struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description,omitempty\"`\n\tWebsite string `json:\"website,omitempty\"`\n\tServiceType string `json:\"serviceType\"`\n\tAuthHeader string `json:\"authHeader,omitempty\"`\n\tBaseURL string `json:\"baseUrl\"`\n\tAPIKeys []string `json:\"apiKeys\"`\n\tProxyURL string `json:\"proxyUrl,omitempty\"`\n\tModelMapping map[string]string `json:\"modelMapping,omitempty\"`\n\tReasoningMapping map[string]string `json:\"reasoningMapping,omitempty\"`\n\tReasoningParamStyle string `json:\"reasoningParamStyle,omitempty\"`\n\tNormalizeSystemRoleToTopLevel bool `json:\"normalizeSystemRoleToTopLevel,omitempty\"`\n\tStripBillingHeader bool `json:\"stripBillingHeader,omitempty\"`\n\tNoVision bool `json:\"noVision,omitempty\"`\n\tNoVisionModels []string `json:\"noVisionModels,omitempty\"`\n\tVisionFallbackModel string `json:\"visionFallbackModel,omitempty\"`\n\tSupportedModels []string `json:\"supportedModels,omitempty\"`\n\tInjectDummyThoughtSignature bool `json:\"injectDummyThoughtSignature,omitempty\"`\n\tStripThoughtSignature bool `json:\"stripThoughtSignature,omitempty\"`\n\tCodexToolCompat bool `json:\"codexToolCompat,omitempty\"`\n\tStripCodexClientTools bool `json:\"stripCodexClientTools,omitempty\"`\n\tNormalizeMetadataUserId *bool `json:\"normalizeMetadataUserId,omitempty\"`\n\tRateLimitRPM int `json:\"rateLimitRpm,omitempty\"`\n\tRateLimitBurst int `json:\"rateLimitBurst,omitempty\"`\n\tRateLimitMaxConcurrent int `json:\"rateLimitMaxConcurrent,omitempty\"`\n\tRateLimitAutoFromHeaders bool `json:\"rateLimitAutoFromHeaders,omitempty\"`\n\tPriority int `json:\"priority,omitempty\"`\n\tStatus string `json:\"status,omitempty\"`\n}\n\nvar providerConsoleURLs = map[string]string{\n\tProviderDeepSeek: \"https://platform.deepseek.com/usage\",\n\tProviderMiMo: \"https://platform.xiaomimimo.com/console/balance\",\n\tProviderCompshare: \"https://console.compshare.cn/light-gpu/model-subscription\",\n\tProviderRunAPI: \"https://runapi.co/console\",\n\tProviderUnity2: \"https://unity2.ai/dashboard\",\n\tProviderKimi: \"https://platform.moonshot.cn/console/account\",\n\tProviderGLM: \"https://open.bigmodel.cn/coding-plan/personal/overview\",\n\tProviderSenseNova: \"https://platform.sensenova.cn/console\",\n\tProviderMiniMax: \"https://platform.minimaxi.com/user-center/payment/balance\",\n\tProviderDashScope: \"https://bailian.console.aliyun.com/cn-beijing?tab=model#/api-key\",\n\tProviderTencentLkeap: \"https://console.cloud.tencent.com/lkeap/token-plan\",\n\tProviderKimiCode: \"https://www.kimi.com/code/console\",\n\tProviderVolcArk: \"https://console.volcengine.com/ark/region:cn-beijing/subscription/coding-plan?projectName=default\",\n\tProviderQianfan: \"https://console.bce.baidu.com/qianfan/resource/subscribe\",\n\tProviderXFyun: \"https://console.xfyun.cn/\",\n\tProviderOriginRouter: \"https://easytransnote.com/ai/console/#key\",\n\tProviderOpenRouter: \"https://openrouter.ai/keys\",\n\tProviderModelScope: \"https://modelscope.cn/my/myaccesstoken\",\n\tProviderOpenCodeZen: \"https://opencode.ai/\",\n\tProviderOpenCodeGo: \"https://opencode.ai/\",\n}\n\n// defaultTargets 返回标准的三目标配置(Messages、Responses、Chat),Messages 默认推荐。\nfunc defaultTargets() []ChannelTarget {\n\treturn []ChannelTarget{\n\t\t{Type: TargetMessages, Label: \"Claude Messages\", Description: \"Claude native Messages protocol, supports Claude Code direct or via CCX proxy\", Recommended: true},\n\t\t{Type: TargetResponses, Label: \"Codex Responses\", Description: \"OpenAI Responses protocol, for Codex CLI and compatible clients\"},\n\t\t{Type: TargetChat, Label: \"OpenAI Chat\", Description: \"OpenAI Chat Completions protocol, compatible with various Chat clients and third-party tools\"},\n\t}\n}\n\n// newFullCapabilityPreset 创建一个具有完整能力的 Preset 模板(全协议支持、全目标、默认 Messages)。\n// 调用者只需设置 ID、Order、Label、Description 和 Plans。\nfunc newFullCapabilityPreset(id, label, description string, order int, plans []ProviderPlan) ProviderPreset {\n\treturn ProviderPreset{\n\t\tID: id,\n\t\tOrder: order,\n\t\tLabel: label,\n\t\tDescription: description,\n\t\tDirectAgent: true,\n\t\tNativeMessages: true,\n\t\tChatCompatible: true,\n\t\tResponsesCompatible: true,\n\t\tPlans: plans,\n\t\tTargets: defaultTargets(),\n\t\tDefaultTarget: TargetMessages,\n\t}\n}\n\n// dualProtocolPlans 生成标准的双协议 Plans(Anthropic + OpenAI)。\n// anthropicURL 是 Anthropic 协议的完整 URL。\n// openaiURL 是 OpenAI 协议的完整 URL。\nfunc dualProtocolPlans(anthropicURL, openaiURL string) []ProviderPlan {\n\treturn []ProviderPlan{\n\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: anthropicURL, Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: openaiURL, Description: \"Chat / Responses 通用入口\"},\n\t}\n}\n\n// dualProtocolPlansSimple 生成标准的双协议 Plans,anthropic 和 openai 使用相同的 baseURL。\n// 适用于 RunAPI、Unity2 等统一入口的服务商。\nfunc dualProtocolPlansSimple(baseURL string) []ProviderPlan {\n\treturn dualProtocolPlans(baseURL, baseURL)\n}\n\nfunc Presets() []ProviderPreset {\n\treturn []ProviderPreset{\n\t\tnewFullCapabilityPreset(\n\t\t\tProviderDeepSeek,\n\t\t\t\"DeepSeek\",\n\t\t\t\"国产高性能推理模型,V4-Pro / V4-Flash 双旗舰,以极低成本实现顶级推理能力,完全开源(MIT),性价比极高。\",\n\t\t\t10,\n\t\t\tdualProtocolPlans(\"https://api.deepseek.com/anthropic\", \"https://api.deepseek.com/v1\"),\n\t\t),\n\t\t{\n\t\t\tID: ProviderMiMo,\n\t\t\tOrder: 20,\n\t\t\tLabel: \"MiMo\",\n\t\t\tDescription: \"小米自研大模型,面向 Agent 时代,旗舰 MiMo-V2.5-Pro 万亿参数、支持百万上下文,深度赋能人车家全生态。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"按量 (Anthropic)\", BaseURL: \"https://api.xiaomimimo.com/anthropic\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"按量 (OpenAI)\", BaseURL: \"https://api.xiaomimimo.com/v1\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t\t{ID: \"token-cn\", Label: \"Token Plan - 中国 (OpenAI)\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\", Description: \"中国区 Token Plan Chat / Responses 通用入口\"},\n\t\t\t\t{ID: \"token-sgp\", Label: \"Token Plan - 新加坡 (OpenAI)\", BaseURL: \"https://token-plan-sgp.xiaomimimo.com/v1\", Description: \"新加坡区 Token Plan Chat / Responses 通用入口\"},\n\t\t\t\t{ID: \"token-ams\", Label: \"Token Plan - 欧洲 (OpenAI)\", BaseURL: \"https://token-plan-ams.xiaomimimo.com/v1\", Description: \"欧洲区 Token Plan Chat / Responses 通用入口\"},\n\t\t\t\t{ID: \"token-cn-anthropic\", Label: \"Token Plan - 中国 (Anthropic)\", BaseURL: \"https://token-plan-cn.xiaomimimo.com/anthropic\", Description: \"中国区 Token Plan Claude Messages 原生入口\"},\n\t\t\t\t{ID: \"token-sgp-anthropic\", Label: \"Token Plan - 新加坡 (Anthropic)\", BaseURL: \"https://token-plan-sgp.xiaomimimo.com/anthropic\", Description: \"新加坡区 Token Plan Claude Messages 原生入口\"},\n\t\t\t\t{ID: \"token-ams-anthropic\", Label: \"Token Plan - 欧洲 (Anthropic)\", BaseURL: \"https://token-plan-ams.xiaomimimo.com/anthropic\", Description: \"欧洲区 Token Plan Claude Messages 原生入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\tnewFullCapabilityPreset(\n\t\t\tProviderCompshare,\n\t\t\t\"优云智算套餐\",\n\t\t\t\"优云智算是 UCloud 旗下 AI 云平台,提供高性价比国内 AI 模型 Agent Plan 套餐,支持包月订阅或按量付费(49 元/月起),同时提供官方海外模型稳定接入,支持 Claude Code、Codex 与 API 集成,具备企业级高并发、7×24 技术支持和自助开票能力;通过推广链接注册可领取 5 元平台试用金。\",\n\t\t\t30,\n\t\t\tdualProtocolPlans(\"https://cp.compshare.cn\", \"https://cp.compshare.cn/v1\"),\n\t\t),\n\t\tnewFullCapabilityPreset(\n\t\t\tProviderRunAPI,\n\t\t\t\"RunAPI\",\n\t\t\t\"RunAPI 是高效稳定的API OpenRouter平替平台,一个 API Key 即可访问 OpenAI、Claude、Gemini、DeepSeek、Grok 等 150+ 主流模型,低至 1 折,极其稳定,可以无缝兼容 Claude Code、OpenClaw 等工具。RunAPI 为 CCX用户提供专属福利:注册联系管理员即可领取¥7的免费额度\",\n\t\t\t40,\n\t\t\tdualProtocolPlansSimple(\"https://runapi.co/v1\"),\n\t\t),\n\t\tnewFullCapabilityPreset(\n\t\t\tProviderUnity2,\n\t\t\t\"Unity2.ai\",\n\t\t\t\"Unity2.ai 是面向个人开发者、团队、企业的高性能 AI 模型 API 中转平台,长期服务国内头部企业,日均承载超 300 亿 token 调用,支持 5000 RPM 级高并发。一个 API Key 即可适配 Claude Code、Codex、OpenAI 模型、IDE 插件和 Agent 工作流等场景。具备企业级稳定供应能力,在高并发、持续调用和团队集中采购场景下依然保持低延迟、高可用。现在注册 Unity2.ai 可领取 $2 余额,加入官方群再送 $10 余额,合计最高可领 $12 免费额度。\",\n\t\t\t45,\n\t\t\tdualProtocolPlansSimple(\"https://unity2.ai/v1\"),\n\t\t),\n\t\t{\n\t\t\tID: ProviderKimi,\n\t\t\tOrder: 50,\n\t\t\tLabel: \"Kimi / Moonshot\",\n\t\t\tDescription: \"月之暗面旗下智能助手平台,旗舰 Kimi K2.6 原生多模态,以超长上下文和 Agent 集群能力著称,深度研究表现突出。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"按量 (Anthropic)\", BaseURL: \"https://api.moonshot.cn/anthropic\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"按量 (OpenAI)\", BaseURL: \"https://api.moonshot.cn/v1\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t\t{ID: \"coding-anthropic\", Label: \"Coding Plan (Anthropic)\", BaseURL: \"https://api.kimi.com/coding\", Description: \"Coding Plan Claude Messages 原生入口\"},\n\t\t\t\t{ID: \"coding-openai-chat\", Label: \"Coding Plan (OpenAI)\", BaseURL: \"https://api.kimi.com/coding/v1\", Description: \"Coding Plan Chat / Responses 通用入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderGLM,\n\t\t\tOrder: 60,\n\t\t\tLabel: \"GLM / BigModel\",\n\t\t\tDescription: \"智谱 AI 大模型开放平台,旗舰 GLM-5.1 编程能力对标前沿闭源模型,港股上市公司,全程华为昇腾训练,性价比突出。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://open.bigmodel.cn/api/anthropic\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"coding\", Label: \"Coding Plan (OpenAI)\", BaseURL: \"https://open.bigmodel.cn/api/coding/paas/v4#\", Description: \"Coding Plan Chat / Responses 通用入口\"},\n\t\t\t\t{ID: \"openai-chat\", Label: \"通用 (OpenAI)\", BaseURL: \"https://open.bigmodel.cn/api/paas/v4#\", Description: \"通用 Chat / Responses 入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderSenseNova,\n\t\t\tOrder: 65,\n\t\t\tLabel: \"SenseNova\",\n\t\t\tDescription: \"商汤日日新 SenseNova LLM API 平台,兼容 OpenAI 与 Anthropic 协议,支持轻量多模态模型和主流编程模型接入。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://token.sensenova.cn\", Description: \"Claude Messages 兼容入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://token.sensenova.cn/v1\", Description: \"Chat 入口,Responses 由 CCX 转换\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderMiniMax,\n\t\t\tOrder: 70,\n\t\t\tLabel: \"MiniMax\",\n\t\t\tDescription: \"全球化多模态 AI 公司(港股上市),旗舰 M3 支持百万上下文、原生视觉与 Agent 能力,海螺视频 / 音频模型全球第一梯队,海外收入超七成。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://api.minimaxi.com/anthropic\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://api.minimax.chat/v1\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderDashScope,\n\t\t\tOrder: 80,\n\t\t\tLabel: \"阿里云 DashScope\",\n\t\t\tDescription: \"阿里云一站式大模型服务平台,集成千问全系列及 DeepSeek 等主流模型,覆盖文本、图像、音视频全模态,提供按量、Coding Plan、Token Plan 多种计费。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"按量 (Anthropic)\", BaseURL: \"https://dashscope.aliyuncs.com/apps/anthropic\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"按量 (OpenAI)\", BaseURL: \"https://dashscope.aliyuncs.com/compatible-mode/v1\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t\t{ID: \"coding-anthropic\", Label: \"Coding Plan (Anthropic)\", BaseURL: \"https://coding.dashscope.aliyuncs.com/apps/anthropic\", Description: \"Coding Plan Claude Messages 原生入口\"},\n\t\t\t\t{ID: \"coding-openai-chat\", Label: \"Coding Plan (OpenAI)\", BaseURL: \"https://coding.dashscope.aliyuncs.com/v1\", Description: \"Coding Plan Chat / Responses 通用入口\"},\n\t\t\t\t{ID: \"token-plan-anthropic\", Label: \"Token Plan (Anthropic)\", BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic\", Description: \"Token Plan Claude Messages 原生入口\"},\n\t\t\t\t{ID: \"token-plan-openai-chat\", Label: \"Token Plan (OpenAI)\", BaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1\", Description: \"Token Plan Chat / Responses 通用入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderOpenCodeZen,\n\t\t\tOrder: 90,\n\t\t\tLabel: \"OpenCode Zen / Go\",\n\t\t\tDescription: \"OpenCode 官方精选 AI 编程模型网关,按量 Zen 与低成本 Go 订阅共用同一套模型预设。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"go-openai-chat\", Label: \"Go (OpenAI-compatible)\", BaseURL: \"https://opencode.ai/zen/go/v1\", Description: \"订阅套餐入口,Messages 经 CCX 转 Chat 上游\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"Zen (OpenAI-compatible)\", BaseURL: \"https://opencode.ai/zen/v1\", Description: \"按量付费入口,Messages 经 CCX 转 Chat 上游\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderOpenCodeGo,\n\t\t\tOrder: 100,\n\t\t\tLabel: \"OpenCode Go\",\n\t\t\tDescription: \"OpenCode 低成本开源编程模型订阅服务($5/月起),深度优化编程 Agent 场景。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://opencode.ai/zen/go/v1\", Description: \"Claude Messages 原生入口\"},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://opencode.ai/zen/go/v1\", Description: \"Messages 经 CCX 转 Chat 上游,Chat / Responses 直连该入口\", Recommended: true},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderTencentLkeap,\n\t\t\tOrder: 85,\n\t\t\tLabel: \"腾讯云 TokenHub\",\n\t\t\tDescription: \"腾讯云大模型服务平台,整合混元及 DeepSeek、Kimi 等主流模型,提供 Token Plan 订阅和按量计费,适配 Claude Code、Codex 等主流编程工具。\",\n\t\t\tDirectAgent: false,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/anthropic\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://api.lkeap.cloud.tencent.com/plan/v3\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderVolcArk,\n\t\t\tOrder: 86,\n\t\t\tLabel: \"火山方舟 Coding Plan\",\n\t\t\tDescription: \"字节跳动火山引擎旗下一站式 AI 平台,提供豆包系列模型精调、推理、评测全链路服务,覆盖多厂商模型,企业级保障完善。\",\n\t\t\tDirectAgent: false,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://ark.cn-beijing.volces.com/api/coding\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderQianfan,\n\t\t\tOrder: 87,\n\t\t\tLabel: \"百度千帆 Coding Plan\",\n\t\t\tDescription: \"百度智能云企业级大模型平台,内置文心 5.0 原生全模态大模型,支持多智能体协同,提供从开发到部署的全链路工具。\",\n\t\t\tDirectAgent: false,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://qianfan.baidubce.com/anthropic/coding\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://qianfan.baidubce.com/v2/coding#\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderXFyun,\n\t\t\tOrder: 88,\n\t\t\tLabel: \"讯飞星辰\",\n\t\t\tDescription: \"科大讯飞星辰 MaaS Astron Coding Plan,提供 OpenAI、Anthropic 与 Responses 三个独立 Coding Plan 入口。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/anthropic\", Description: \"Claude Messages Coding Plan 入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v2\", Description: \"OpenAI Chat Coding Plan 入口\"},\n\t\t\t\t{ID: \"responses\", Label: \"Responses-compatible\", BaseURL: \"https://maas-coding-api.cn-huabei-1.xf-yun.com/v1/responses\", Description: \"OpenAI Responses Coding Plan 入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderOpenRouter,\n\t\t\tOrder: 35,\n\t\t\tLabel: \"OpenRouter\",\n\t\t\tDescription: \"全球最大的 AI 模型聚合平台,一个 API 密钥访问数百个模型,兼容 OpenAI 接口,提供安全治理和预算管控能力。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://openrouter.ai/api\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://openrouter.ai/api/v1\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\tID: ProviderModelScope,\n\t\t\tOrder: 82,\n\t\t\tLabel: \"ModelScope 魔搭\",\n\t\t\tDescription: \"阿里达摩院发起的开源模型社区,汇聚上万个模型,覆盖文本、图像、语音等全模态,支持模型探索、推理与训练全流程。\",\n\t\t\tDirectAgent: true,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://api-inference.modelscope.cn\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://api-inference.modelscope.cn/v1\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t\t{\n\t\t\t// 极易云仅在渠道中心可用,刻意不纳入 Agent(Claude/Codex/OpenCode)直连下拉。\n\t\t\t// 即便三个协议标志均为 true,也不要将其加入 desktop/internal/configservice 的直连 provider 列表。\n\t\t\tID: ProviderOriginRouter,\n\t\t\tOrder: 200,\n\t\t\tLabel: \"极易云 OriginRouter\",\n\t\t\tDescription: \"极易云统一模型 API 转发平台,一个密钥无缝切换 GPT、Claude、Gemini 等主流模型,高速稳定,适合多模型混用场景。\",\n\t\t\tDirectAgent: false,\n\t\t\tNativeMessages: true,\n\t\t\tChatCompatible: true,\n\t\t\tResponsesCompatible: true,\n\t\t\tPlans: []ProviderPlan{\n\t\t\t\t{ID: \"anthropic\", Label: \"Anthropic-compatible\", BaseURL: \"https://api.easytransnote.com/coding\", Description: \"Claude Messages 原生入口\", Recommended: true},\n\t\t\t\t{ID: \"openai-chat\", Label: \"OpenAI-compatible\", BaseURL: \"https://api.easytransnote.com/coding/v1\", Description: \"Chat / Responses 通用入口\"},\n\t\t\t},\n\t\t\tTargets: defaultTargets(),\n\t\t\tDefaultTarget: TargetMessages,\n\t\t},\n\t}\n}\n\nfunc BuildPayload(req CreateChannelRequest) (ChannelPayload, error) {\n\tif strings.EqualFold(strings.TrimSpace(req.Provider), ProviderGitHubCopilot) {\n\t\treturn buildGitHubCopilotPayload(req)\n\t}\n\n\tpreset, ok := FindPreset(req.Provider)\n\tif !ok {\n\t\treturn ChannelPayload{}, fmt.Errorf(\"不支持的 provider: %s\", req.Provider)\n\t}\n\ttarget := strings.TrimSpace(req.Target)\n\tif target == \"\" {\n\t\ttarget = preset.DefaultTarget\n\t}\n\tif !supportsTarget(preset, target) {\n\t\treturn ChannelPayload{}, fmt.Errorf(\"%s 不支持添加到 %s 渠道\", preset.Label, target)\n\t}\n\tapiKey := strings.TrimSpace(req.APIKey)\n\tif apiKey == \"\" {\n\t\treturn ChannelPayload{}, fmt.Errorf(\"API Key 不能为空\")\n\t}\n\tplanID := strings.TrimSpace(req.PlanID)\n\tif planID == \"\" {\n\t\tplanID = bestPlanForTarget(preset, target)\n\t}\n\tbaseURL, err := ResolveBaseURL(preset, planID, req.BaseURL)\n\tif err != nil {\n\t\treturn ChannelPayload{}, err\n\t}\n\tname := strings.TrimSpace(req.Name)\n\tif name == \"\" {\n\t\tname = defaultChannelNameForPlan(preset.ID, target, planID)\n\t}\n\tpayload := ChannelPayload{\n\t\tName: name,\n\t\tDescription: strings.TrimSpace(req.Description),\n\t\tWebsite: providerConsoleURLs[preset.ID],\n\t\tBaseURL: baseURL,\n\t\tAPIKeys: []string{apiKey},\n\t\tPriority: 1,\n\t\tStatus: \"active\",\n\t}\n\tapplyTargetDefaults(&payload, preset.ID, target, planID)\n\tif targetConfigs, ok := channelTargetConfigs[target]; ok {\n\t\tif targetConfig, ok := targetConfigs[preset.ID]; ok {\n\t\t\tif preset.ID == ProviderKimi {\n\t\t\t\ttargetConfig = applyKimiPlanOverrides(targetConfig, target, planID)\n\t\t\t}\n\t\t\tapplyChannelTargetConfig(&payload, targetConfig)\n\t\t}\n\t}\n\treturn payload, nil\n}\n\nfunc buildGitHubCopilotPayload(req CreateChannelRequest) (ChannelPayload, error) {\n\ttarget := strings.TrimSpace(req.Target)\n\tif target == \"\" {\n\t\ttarget = TargetResponses\n\t}\n\tif !isGitHubCopilotTarget(target) {\n\t\treturn ChannelPayload{}, fmt.Errorf(\"GitHub Copilot 不支持添加到 %s 渠道\", target)\n\t}\n\n\tapiKey := strings.TrimSpace(req.APIKey)\n\tif apiKey == \"\" {\n\t\treturn ChannelPayload{}, fmt.Errorf(\"API Key 不能为空\")\n\t}\n\n\tbaseURL := strings.TrimSpace(req.BaseURL)\n\tif baseURL == \"\" {\n\t\tbaseURL = \"https://api.githubcopilot.com\"\n\t}\n\tname := strings.TrimSpace(req.Name)\n\tif name == \"\" {\n\t\tname = defaultChannelName(ProviderGitHubCopilot, target)\n\t}\n\n\treturn ChannelPayload{\n\t\tName: name,\n\t\tDescription: strings.TrimSpace(req.Description),\n\t\tWebsite: \"https://github.com/settings/copilot\",\n\t\tServiceType: \"copilot\",\n\t\tBaseURL: baseURL,\n\t\tAPIKeys: []string{apiKey},\n\t\tProxyURL: strings.TrimSpace(req.ProxyURL),\n\t\tPriority: 1,\n\t\tStatus: \"active\",\n\t}, nil\n}\n\nfunc isGitHubCopilotTarget(target string) bool {\n\tswitch target {\n\tcase TargetMessages, TargetChat, TargetResponses, TargetGemini:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// targetMatchesURL 判断 URL 是否与 target 协议兼容。\n// messages target 使用 Anthropic 协议,需要 anthropic 路径;\n// chat/responses target 使用 OpenAI 协议,不应使用 anthropic 路径。\n\n// bestPlanForTarget 根据 target 自动选择最合适的 plan。\n// 当 provider 有多个 plan(如 DeepSeek 的 /anthropic 和 /v1)时,\n// 确保 chat/responses 选择 OpenAI-compatible 入口,messages 选择 Anthropic 入口。\nfunc bestPlanForTarget(preset ProviderPreset, target string) string {\n\tif len(preset.Plans) == 0 {\n\t\treturn \"\"\n\t}\n\tif len(preset.Plans) == 1 {\n\t\treturn preset.Plans[0].ID\n\t}\n\tif usesChatUpstreamForMessages(preset.ID) && target == TargetMessages {\n\t\tfor _, plan := range preset.Plans {\n\t\t\tif !plan.Custom && plan.Recommended && isChatUpstreamPlan(plan) {\n\t\t\t\treturn plan.ID\n\t\t\t}\n\t\t}\n\t\tfor _, plan := range preset.Plans {\n\t\t\tif isChatUpstreamPlan(plan) {\n\t\t\t\treturn plan.ID\n\t\t\t}\n\t\t}\n\t}\n\tif target == TargetResponses {\n\t\tfor _, plan := range preset.Plans {\n\t\t\tif !plan.Custom && plan.Protocol() == \"responses\" {\n\t\t\t\treturn plan.ID\n\t\t\t}\n\t\t}\n\t}\n\tfor _, plan := range preset.Plans {\n\t\tif !plan.Custom && planCompatibleWithTarget(plan, target) {\n\t\t\treturn plan.ID\n\t\t}\n\t}\n\tfor _, plan := range preset.Plans {\n\t\tif plan.Recommended {\n\t\t\treturn plan.ID\n\t\t}\n\t}\n\treturn preset.Plans[0].ID\n}\n\nfunc FindPreset(provider string) (ProviderPreset, bool) {\n\tprovider = strings.ToLower(strings.TrimSpace(provider))\n\tfor _, preset := range Presets() {\n\t\tif preset.ID == provider {\n\t\t\treturn preset, true\n\t\t}\n\t}\n\treturn ProviderPreset{}, false\n}\n\n// FilterPlansForTarget 按 target 协议过滤 plans,只返回兼容的入口。\n// messages 使用 Anthropic 协议,chat/responses 使用 OpenAI 协议。\n// 自定义 plan 始终保留。\nfunc FilterPlansForTarget(preset ProviderPreset, target string) []ProviderPlan {\n\tplans := preset.Plans\n\tif len(plans) <= 1 {\n\t\treturn plans\n\t}\n\tif usesChatUpstreamForMessages(preset.ID) && target == TargetMessages {\n\t\tfiltered := make([]ProviderPlan, 0, len(plans))\n\t\tfor _, plan := range plans {\n\t\t\tif plan.Custom || isChatUpstreamPlan(plan) {\n\t\t\t\tfiltered = append(filtered, plan)\n\t\t\t}\n\t\t}\n\t\tif len(filtered) > 0 {\n\t\t\treturn filtered\n\t\t}\n\t}\n\tvar filtered []ProviderPlan\n\tfor _, plan := range plans {\n\t\tif planCompatibleWithTarget(plan, target) {\n\t\t\tfiltered = append(filtered, plan)\n\t\t}\n\t}\n\tif len(filtered) == 0 {\n\t\treturn plans\n\t}\n\treturn filtered\n}\n\nfunc usesChatUpstreamForMessages(provider string) bool {\n\treturn provider == ProviderOpenCodeZen || provider == ProviderOpenCodeGo\n}\n\nfunc isChatUpstreamPlan(plan ProviderPlan) bool {\n\treturn plan.Protocol() == \"openai\"\n}\n\nfunc planCompatibleWithTarget(plan ProviderPlan, target string) bool {\n\tif plan.Custom {\n\t\treturn true\n\t}\n\n\tprotocol := plan.Protocol()\n\tswitch target {\n\tcase TargetMessages:\n\t\treturn protocol == \"anthropic\"\n\tcase TargetChat:\n\t\treturn protocol == \"openai\"\n\tcase TargetResponses:\n\t\treturn protocol == \"responses\" || protocol == \"openai\"\n\tdefault:\n\t\treturn true\n\t}\n}\n\nfunc ResolveBaseURL(preset ProviderPreset, planID string, customBaseURL string) (string, error) {\n\tcustomBaseURL = strings.TrimSpace(customBaseURL)\n\tplanID = strings.TrimSpace(planID)\n\tif planID == \"\" {\n\t\tfor _, plan := range preset.Plans {\n\t\t\tif plan.Recommended {\n\t\t\t\tplanID = plan.ID\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\tif planID == \"\" && len(preset.Plans) > 0 {\n\t\tplanID = preset.Plans[0].ID\n\t}\n\tfor _, plan := range preset.Plans {\n\t\tif plan.ID != planID {\n\t\t\tcontinue\n\t\t}\n\t\tif plan.Custom {\n\t\t\tif customBaseURL == \"\" {\n\t\t\t\treturn \"\", fmt.Errorf(\"自定义 plan 需要填写 Base URL\")\n\t\t\t}\n\t\t\treturn customBaseURL, nil\n\t\t}\n\t\tif customBaseURL != \"\" {\n\t\t\treturn customBaseURL, nil\n\t\t}\n\t\tif plan.BaseURL == \"\" {\n\t\t\treturn \"\", fmt.Errorf(\"plan %s 缺少 Base URL\", plan.ID)\n\t\t}\n\t\treturn plan.BaseURL, nil\n\t}\n\tif customBaseURL != \"\" {\n\t\treturn customBaseURL, nil\n\t}\n\treturn \"\", fmt.Errorf(\"未找到 provider %s 的 plan: %s\", preset.ID, planID)\n}\n\nfunc supportsTarget(preset ProviderPreset, target string) bool {\n\tfor _, item := range preset.Targets {\n\t\tif item.Type == target {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc defaultChannelName(provider string, target string) string {\n\treturn fmt.Sprintf(\"desktop-%s-%s\", provider, target)\n}\n\nfunc defaultChannelNameForPlan(provider string, target string, planID string) string {\n\tif provider == ProviderOpenCodeZen && strings.HasPrefix(strings.TrimSpace(planID), \"go-\") {\n\t\treturn defaultChannelName(ProviderOpenCodeGo, target)\n\t}\n\treturn defaultChannelName(provider, target)\n}\n\ntype channelTargetConfig struct {\n\tServiceType string\n\tAuthHeader string\n\tModelMapping map[string]string\n\tReasoningMapping map[string]string\n\tReasoningParamStyle string\n\tNormalizeSystemRoleToTopLevel bool\n\tNormalizeMetadataUserId *bool\n\tStripBillingHeader bool\n\tNoVision bool\n\tNoVisionModels []string\n\tVisionFallbackModel string\n\tCodexToolCompat *bool\n\tStripCodexClientTools *bool\n\tRateLimitRPM int // 主动限速默认 RPM(0=不设默认)\n}\n\nvar channelTargetConfigs = map[string]map[string]channelTargetConfig{\n\tTargetMessages: generatedClaudeMessagesTargetConfigs(),\n\tTargetChat: generatedOpenAIChatTargetConfigs(),\n\tTargetResponses: generatedCodexResponsesTargetConfigs(),\n}\n\nfunc boolRef(value bool) *bool {\n\treturn &value\n}\n\n// applyKimiPlanOverrides 根据 Kimi planID 覆盖模型映射。\n// coding plan 使用 kimi-for-coding,普通按量使用 kimi-k2.7。\nfunc applyKimiPlanOverrides(config channelTargetConfig, target string, planID string) channelTargetConfig {\n\tisCodingPlan := strings.HasPrefix(planID, \"coding-\")\n\n\tif target == TargetMessages {\n\t\tif isCodingPlan {\n\t\t\t// Coding Plan: 使用 kimi-for-coding\n\t\t\tconfig.ModelMapping = map[string]string{\n\t\t\t\t\"fable\": \"kimi-for-coding\",\n\t\t\t\t\"haiku\": \"kimi-for-coding\",\n\t\t\t\t\"opus\": \"kimi-for-coding\",\n\t\t\t\t\"sonnet\": \"kimi-for-coding\",\n\t\t\t}\n\t\t} else {\n\t\t\t// 普通按量: 使用 kimi-k2.7\n\t\t\tconfig.ModelMapping = map[string]string{\n\t\t\t\t\"fable\": \"kimi-k2.7\",\n\t\t\t\t\"haiku\": \"kimi-k2.7\",\n\t\t\t\t\"opus\": \"kimi-k2.7\",\n\t\t\t\t\"sonnet\": \"kimi-k2.7\",\n\t\t\t}\n\t\t}\n\t} else if target == TargetResponses {\n\t\tif isCodingPlan {\n\t\t\t// Coding Plan: 使用 kimi-for-coding\n\t\t\tconfig.ModelMapping = map[string]string{\n\t\t\t\t\"codex\": \"kimi-for-coding\",\n\t\t\t\t\"gpt\": \"kimi-for-coding\",\n\t\t\t}\n\t\t} else {\n\t\t\t// 普通按量: 使用 kimi-k2.7\n\t\t\tconfig.ModelMapping = map[string]string{\n\t\t\t\t\"codex\": \"kimi-k2.7\",\n\t\t\t\t\"gpt\": \"kimi-k2.7\",\n\t\t\t}\n\t\t}\n\t}\n\n\treturn config\n}\n\nfunc applyChannelTargetConfig(payload *ChannelPayload, config channelTargetConfig) {\n\tif config.ServiceType != \"\" {\n\t\tpayload.ServiceType = config.ServiceType\n\t}\n\tif config.AuthHeader != \"\" {\n\t\tpayload.AuthHeader = config.AuthHeader\n\t}\n\tpayload.ModelMapping = maps.Clone(config.ModelMapping)\n\tpayload.ReasoningMapping = maps.Clone(config.ReasoningMapping)\n\tpayload.NoVisionModels = slices.Clone(config.NoVisionModels)\n\tpayload.ReasoningParamStyle = config.ReasoningParamStyle\n\tpayload.NormalizeSystemRoleToTopLevel = payload.NormalizeSystemRoleToTopLevel || config.NormalizeSystemRoleToTopLevel\n\tif config.NormalizeMetadataUserId != nil {\n\t\tpayload.NormalizeMetadataUserId = config.NormalizeMetadataUserId\n\t}\n\tpayload.StripBillingHeader = payload.StripBillingHeader || config.StripBillingHeader\n\tpayload.NoVision = payload.NoVision || config.NoVision\n\tpayload.VisionFallbackModel = config.VisionFallbackModel\n\tif config.CodexToolCompat != nil {\n\t\tpayload.CodexToolCompat = *config.CodexToolCompat\n\t}\n\tif config.StripCodexClientTools != nil {\n\t\tpayload.StripCodexClientTools = *config.StripCodexClientTools\n\t}\n\tif config.RateLimitRPM > 0 {\n\t\tpayload.RateLimitRPM = config.RateLimitRPM\n\t}\n}\n\nfunc applyTargetDefaults(payload *ChannelPayload, provider string, target string, planID string) {\n\tswitch target {\n\tcase TargetMessages:\n\t\tpayload.ServiceType = \"claude\"\n\t\tpayload.StripThoughtSignature = true\n\t\tif provider == ProviderOpenCodeGo {\n\t\t\tpayload.AuthHeader = \"x-api-key\"\n\t\t}\n\t\tif provider == ProviderRunAPI {\n\t\t\tpayload.NormalizeMetadataUserId = boolRef(false)\n\t\t}\n\tcase TargetChat:\n\t\tpayload.ServiceType = \"openai\"\n\tcase TargetResponses:\n\t\tpayload.ServiceType = \"openai\"\n\t\tpayload.CodexToolCompat = true\n\t\tpayload.StripCodexClientTools = true\n\t\tif provider == ProviderRunAPI || provider == ProviderUnity2 {\n\t\t\tpayload.ServiceType = \"responses\"\n\t\t\tpayload.CodexToolCompat = false\n\t\t\tpayload.StripCodexClientTools = false\n\t\t}\n\t\tif provider == ProviderXFyun && planID == \"responses\" {\n\t\t\tpayload.ServiceType = \"responses\"\n\t\t}\n\t}\n\n\tconfigs, ok := channelTargetConfigs[target]\n\tif !ok {\n\t\treturn\n\t}\n\tconfig, ok := configs[provider]\n\tif !ok {\n\t\treturn\n\t}\n\n\t// Kimi 根据 planID 选择不同的模型映射\n\tif provider == ProviderKimi {\n\t\tconfig = applyKimiPlanOverrides(config, target, planID)\n\t}\n\n\tapplyChannelTargetConfig(payload, config)\n}\n"}}, "desktop/frontend/src/composables/useAgentConfig.ts": {"784041ebc2a680321b17c44db2f89e95b9470dbe": {"t": 1785809840.5277042, "n": 23905, "h": "c1f3d0a7c64e511c", "c": "import { ref, computed, type ComputedRef } from 'vue'\nimport type { AgentPlatform, AgentProvider, AgentConfigStatus, ApplyAgentConfigRequest, ConfigDiffResult, MigrateCodexSessionsResult } from '@/types'\nimport { useLanguage } from '@/composables/useLanguage'\nimport {\n GetAgentConfigStatus,\n ApplyAgentConfig,\n RestoreAgentConfig,\n GetSavedProviderKeys,\n PreviewAgentConfigDiff,\n PreviewRestoreConfigDiff,\n MigrateCodexSessions,\n} from '@bindings/github.com/BenedictKing/ccx/desktop/desktopservice'\n\nconst agentLabels: Record = {\n claude: 'Claude Code',\n codex: 'Codex',\n opencode: 'OpenCode',\n}\n\nconst claudeProviderLabels: Record = {\n ccx: 'CCX',\n deepseek: 'DeepSeek',\n mimo: 'MiMo',\n compshare: 'Compshare',\n runapi: 'RunAPI',\n unity2: 'Unity2.ai',\n 'tencent-lkeap': '腾讯云 TokenHub',\n 'kimi-code': 'Kimi Code',\n 'volc-ark': '火山方舟',\n qianfan: '百度千帆',\n originrouter: '极易云',\n kimi: 'Kimi',\n glm: 'GLM',\n sensenova: 'SenseNova',\n minimax: 'MiniMax',\n dashscope: 'DashScope',\n openrouter: 'OpenRouter',\n modelscope: 'ModelScope',\n 'opencode-zen': 'OpenCode Zen',\n 'opencode-go': 'OpenCode Go',\n openai: 'OpenAI',\n xfyun: '讯飞星辰',\n // custom 需要 i18n,延迟到函数内获取\n custom: 'Custom',\n}\n\n// codexProviderLabels 需要响应式 t(),延迟到 useAgentConfig() 内构建\nlet _codexProviderLabels: ComputedRef> | null = null\n\nfunction getCodexProviderLabels(): ComputedRef> {\n if (!_codexProviderLabels) {\n const { t } = useLanguage()\n _codexProviderLabels = computed>(() => ({\n ccx: t('agent.localGateway'),\n openai: 'OpenAI',\n deepseek: 'DeepSeek',\n mimo: 'MiMo',\n compshare: 'Compshare',\n runapi: 'RunAPI',\n unity2: 'Unity2.ai',\n 'tencent-lkeap': '腾讯云 TokenHub',\n 'kimi-code': 'Kimi Code',\n 'volc-ark': '火山方舟',\n qianfan: '百度千帆',\n originrouter: '极易云',\n kimi: 'Kimi',\n glm: 'GLM',\n sensenova: 'SenseNova',\n minimax: 'MiniMax',\n dashscope: 'DashScope',\n openrouter: 'OpenRouter',\n modelscope: 'ModelScope',\n 'opencode-zen': 'OpenCode Zen',\n 'opencode-go': 'OpenCode Go',\n xfyun: '讯飞星辰',\n custom: t('agent.custom'),\n }))\n }\n return _codexProviderLabels!\n}\n\n// 延迟获取 t 函数,首次 useAgentConfig() 调用时初始化\nlet _t: ((key: string, params?: Record) => string) | null = null\n\nfunction getT() {\n if (!_t) {\n _t = useLanguage().t\n }\n return _t\n}\n\nconst agentPlatforms: AgentPlatform[] = ['claude', 'codex', 'opencode']\n\n// Module-level singletons\nconst agentStatuses = ref>({\n claude: null,\n codex: null,\n opencode: null,\n})\nconst configLoading = ref(false)\nconst selectedClaudeProvider = ref('ccx')\nconst claudeProviderKeys = ref>({\n ccx: '',\n deepseek: '',\n mimo: '',\n compshare: '',\n runapi: '',\n unity2: '',\n 'tencent-lkeap': '',\n 'kimi-code': '',\n 'volc-ark': '',\n qianfan: '',\n originrouter: '',\n kimi: '',\n glm: '',\n sensenova: '',\n minimax: '',\n dashscope: '',\n openrouter: '',\n modelscope: '',\n 'opencode-zen': '',\n 'opencode-go': '',\n openai: '',\n xfyun: '',\n})\nconst savedProviderKeys = ref>({})\nconst codexOpenAIKey = ref('')\n// OpenAI 直连:是否勾选「我有自己的 API Key」。勾选则必须输入 key,否则走 OAuth。\nconst codexOpenAIUseOwnKey = ref(false)\nconst openCodeOpenAIKey = ref('')\nconst claudeMimoBaseUrl = ref('https://api.xiaomimimo.com/anthropic')\nconst selectedMimoPlan = ref('https://api.xiaomimimo.com/anthropic')\nconst selectedMimoCodexPlan = ref('https://api.xiaomimimo.com/v1')\nconst selectedMimoOpenCodePlan = ref('https://api.xiaomimimo.com/v1')\nconst selectedDashScopePlan = ref('https://dashscope.aliyuncs.com/apps/anthropic')\nconst selectedCodexProvider = ref('ccx')\nconst codexMode = ref<'quick' | 'plugin'>('quick')\nconst selectedOpenCodeProvider = ref('ccx')\n\n// Diff preview dialog state\nconst diffDialogOpen = ref(false)\nconst diffResult = ref(null)\nconst diffMode = ref<'apply' | 'restore'>('apply')\nconst diffLoading = ref(false)\nconst diffPendingPlatform = ref('claude')\nconst diffWarning = ref(undefined)\nconst migrateDialogOpen = ref(false)\nconst migrateLoading = ref(false)\nconst migrateResult = ref(null)\nconst migrateError = ref('')\n\nconst isClaudeProvider = (value?: string): value is AgentProvider => {\n return value === 'ccx' || value === 'deepseek' || value === 'mimo' || value === 'compshare' || value === 'runapi' || value === 'unity2' || value === 'tencent-lkeap' || value === 'kimi' || value === 'glm' || value === 'sensenova' || value === 'minimax' || value === 'dashscope' || value === 'openrouter' || value === 'modelscope' || value === 'opencode-zen' || value === 'opencode-go' || value === 'xfyun' || value === 'volc-ark' || value === 'qianfan'\n}\n\n// Codex 支持快捷模式/插件模式切换的第三方 provider\nconst isCodexThirdPartyWithMode = (provider?: string) => {\n return provider === 'deepseek' || provider === 'mimo' || provider === 'compshare' || provider === 'runapi' || provider === 'unity2' || provider === 'kimi' || provider === 'glm' || provider === 'minimax' || provider === 'dashscope' || provider === 'openrouter' || provider === 'modelscope' || provider === 'opencode-zen' || provider === 'opencode-go' || provider === 'xfyun' || provider === 'tencent-lkeap' || provider === 'volc-ark' || provider === 'qianfan'\n}\n\nconst claudeProviderLabel = (value?: string) => {\n if (!value) return getT()('agent.statusDetecting')\n return claudeProviderLabels[value as AgentProvider | 'custom'] || value\n}\n\nconst codexProviderLabel = (value?: string) => {\n if (!value) return getT()('agent.statusDetecting')\n return getCodexProviderLabels().value[value as AgentProvider | 'custom'] || value\n}\n\nconst openCodeProviderLabel = (value?: string) => {\n if (!value) return getT()('agent.statusDetecting')\n return getCodexProviderLabels().value[value as AgentProvider | 'custom'] || value\n}\n\nconst claudeTargetBaseUrl = () => {\n switch (selectedClaudeProvider.value) {\n case 'ccx':\n return agentStatuses.value.claude?.targetBaseUrl || getT()('agent.localGateway')\n case 'deepseek':\n return 'https://api.deepseek.com/anthropic'\n case 'mimo':\n return claudeMimoBaseUrl.value || 'https://api.xiaomimimo.com/anthropic'\n case 'compshare':\n return 'https://cp.compshare.cn'\n case 'runapi':\n return 'https://runapi.co/v1'\n case 'unity2':\n return 'https://unity2.ai/v1'\n case 'kimi':\n return 'https://api.moonshot.cn/anthropic'\n case 'glm':\n return 'https://open.bigmodel.cn/api/anthropic'\n case 'sensenova':\n return 'https://token.sensenova.cn'\n case 'minimax':\n return 'https://api.minimaxi.com/anthropic'\n case 'dashscope':\n return selectedDashScopePlan.value\n case 'openrouter':\n return 'https://openrouter.ai/api'\n case 'modelscope':\n return 'https://api-inference.modelscope.cn'\n case 'opencode-zen':\n return 'https://opencode.ai/zen'\n case 'opencode-go':\n return 'https://opencode.ai/zen/go'\n case 'xfyun':\n return 'https://maas-coding-api.cn-huabei-1.xf-yun.com/anthropic'\n case 'tencent-lkeap':\n return 'https://api.lkeap.cloud.tencent.com/plan/anthropic'\n case 'volc-ark':\n return 'https://ark.cn-beijing.volces.com/api/coding'\n case 'qianfan':\n return 'https://qianfan.baidubce.com/anthropic/coding'\n default:\n return ''\n }\n}\n\nconst codexTargetBaseUrl = () => {\n switch (selectedCodexProvider.value) {\n case 'ccx':\n return agentStatuses.value.codex?.targetBaseUrl || getT()('agent.localGateway')\n case 'openai':\n return 'https://api.openai.com/v1'\n case 'deepseek':\n return 'https://api.deepseek.com/v1'\n case 'mimo':\n return selectedMimoCodexPlan.value || 'https://api.xiaomimimo.com/v1'\n case 'compshare':\n return 'https://cp.compshare.cn/v1'\n case 'kimi':\n return 'https://api.moonshot.cn/v1'\n case 'glm':\n return 'https://open.bigmodel.cn/api/paas/v4'\n case 'minimax':\n return 'https://api.minimax.chat/v1'\n case 'dashscope':\n return 'https://dashscope.aliyuncs.com/compatible-mode/v1'\n case 'runapi':\n return 'https://runapi.co/v1'\n case 'unity2':\n return 'https://unity2.ai/v1'\n case 'openrouter':\n return 'https://openrouter.ai/api/v1'\n case 'modelscope':\n return 'https://api-inference.modelscope.cn/v1'\n case 'opencode-zen':\n return 'https://opencode.ai/zen/v1'\n case 'opencode-go':\n return 'https://opencode.ai/zen/go/v1'\n case 'xfyun':\n return 'https://maas-coding-api.cn-huabei-1.xf-yun.com/v2'\n case 'tencent-lkeap':\n return 'https://api.lkeap.cloud.tencent.com/plan/v3'\n case 'volc-ark':\n return 'https://ark.cn-beijing.volces.com/api/coding/v3'\n case 'qianfan':\n return 'https://qianfan.baidubce.com/v2/coding'\n default:\n return ''\n }\n}\n\nconst openCodeTargetBaseUrl = () => {\n switch (selectedOpenCodeProvider.value) {\n case 'ccx':\n return agentStatuses.value.opencode?.targetBaseUrl || getT()('agent.localGateway')\n case 'deepseek':\n return 'https://api.deepseek.com/v1'\n case 'mimo':\n return selectedMimoOpenCodePlan.value || 'https://api.xiaomimimo.com/v1'\n case 'compshare':\n return 'https://cp.compshare.cn/v1'\n case 'kimi':\n return 'https://api.moonshot.cn/v1'\n case 'glm':\n return 'https://open.bigmodel.cn/api/paas/v4'\n case 'sensenova':\n return 'https://token.sensenova.cn/v1'\n case 'minimax':\n return 'https://api.minimax.chat/v1'\n case 'runapi':\n return 'https://runapi.co/v1'\n case 'unity2':\n return 'https://unity2.ai/v1'\n case 'dashscope':\n return 'https://dashscope.aliyuncs.com/compatible-mode/v1'\n case 'xfyun':\n return 'https://maas-coding-api.cn-huabei-1.xf-yun.com/v2'\n case 'openrouter':\n return 'https://openrouter.ai/api/v1'\n case 'modelscope':\n return 'https://api-inference.modelscope.cn/v1'\n case 'opencode-zen':\n return 'https://opencode.ai/zen/v1'\n case 'opencode-go':\n return 'https://opencode.ai/zen/go/v1'\n case 'tencent-lkeap':\n return 'https://api.lkeap.cloud.tencent.com/plan/v3'\n case 'volc-ark':\n return 'https://ark.cn-beijing.volces.com/api/coding/v3'\n case 'qianfan':\n return 'https://qianfan.baidubce.com/v2/coding'\n default:\n return ''\n }\n}\n\nconst agentStatusText = (item: AgentConfigStatus | null) => {\n const t = getT()\n if (!item) return t('agent.statusDetecting')\n if (item.configured) return t('agent.statusConfigured')\n if (item.needsUpdate) return t('agent.statusPortMismatch')\n return t('agent.statusUnconfigured')\n}\n\nconst agentStatusClass = (item: AgentConfigStatus | null) => {\n if (!item) return 'starting'\n if (item.configured) return 'running'\n if (item.needsUpdate) return 'starting'\n return 'stopped'\n}\n\nconst resolveMiMoPlan = (url: string): string => {\n const known = [\n 'https://api.xiaomimimo.com/anthropic',\n 'https://token-plan-cn.xiaomimimo.com/anthropic',\n 'https://token-plan-sgp.xiaomimimo.com/anthropic',\n 'https://token-plan-ams.xiaomimimo.com/anthropic',\n ]\n return known.includes(url) ? url : ''\n}\n\nconst resolveMiMoCodexPlan = (url: string): string => {\n const known = [\n 'https://api.xiaomimimo.com/v1',\n 'https://token-plan-cn.xiaomimimo.com/v1',\n 'https://token-plan-sgp.xiaomimimo.com/v1',\n 'https://token-plan-ams.xiaomimimo.com/v1',\n ]\n return known.includes(url) ? url : ''\n}\n\nconst resolveDashScopePlan = (url: string): string => {\n const known = [\n 'https://dashscope.aliyuncs.com/apps/anthropic',\n 'https://coding.dashscope.aliyuncs.com/apps/anthropic',\n ]\n return known.includes(url) ? url : ''\n}\n\nconst loadAgentStatuses = async () => {\n configLoading.value = true\n try {\n const [claude, codex, opencode, keys] = await Promise.all([\n GetAgentConfigStatus('claude') as Promise,\n GetAgentConfigStatus('codex') as Promise,\n GetAgentConfigStatus('opencode') as Promise,\n GetSavedProviderKeys(),\n ])\n agentStatuses.value = { claude, codex, opencode }\n savedProviderKeys.value = Object.fromEntries(\n Object.entries(keys).filter((entry): entry is [string, string] => typeof entry[1] === 'string')\n )\n if (isClaudeProvider(claude.provider)) {\n selectedClaudeProvider.value = claude.provider\n }\n if (claude.provider === 'mimo' && claude.currentBaseUrl) {\n claudeMimoBaseUrl.value = claude.currentBaseUrl\n selectedMimoPlan.value = resolveMiMoPlan(claude.currentBaseUrl)\n }\n if (claude.provider === 'dashscope' && claude.currentBaseUrl) {\n selectedDashScopePlan.value = resolveDashScopePlan(claude.currentBaseUrl)\n }\n if (codex.provider && codex.provider !== 'ccx' && codex.provider !== '') {\n selectedCodexProvider.value = codex.provider as AgentProvider\n } else {\n selectedCodexProvider.value = 'ccx'\n }\n if (codex.provider === 'mimo' && codex.currentBaseUrl) {\n selectedMimoCodexPlan.value = resolveMiMoCodexPlan(codex.currentBaseUrl) || 'https://api.xiaomimimo.com/v1'\n }\n // 恢复所有支持 mode 切换的 provider 的 mode 状态\n if (codex.provider === 'ccx' || codex.provider === 'dashscope' || codex.provider === 'runapi' || codex.provider === 'opencode-zen' || codex.provider === 'opencode-go') {\n codexMode.value = codex.mode === 'plugin' ? 'plugin' : 'quick'\n }\n if (opencode.provider && opencode.provider !== 'ccx' && opencode.provider !== '') {\n selectedOpenCodeProvider.value = opencode.provider as AgentProvider\n } else {\n selectedOpenCodeProvider.value = 'ccx'\n }\n if (opencode.provider === 'mimo' && opencode.currentBaseUrl) {\n selectedMimoOpenCodePlan.value = resolveMiMoCodexPlan(opencode.currentBaseUrl) || 'https://api.xiaomimimo.com/v1'\n }\n } catch (error) {\n // error is handled by caller\n } finally {\n configLoading.value = false\n }\n}\n\nconst findSavedKey = (provider: string, planID?: string): string => {\n if (planID) {\n const planKey = savedProviderKeys.value[`claude:${provider}:${planID}`]\n if (planKey) return planKey\n }\n return savedProviderKeys.value[`claude:${provider}`] || ''\n}\n\nconst canApplyAgent = (platform: AgentPlatform) => {\n if (configLoading.value) return false\n if (platform === 'codex') {\n // CCX 用 proxy key,无需验证\n if (selectedCodexProvider.value === 'ccx') {\n return true\n }\n // OpenAI 直连:不勾选走 OAuth;勾选「我有自己的 API Key」则必须输入\n if (selectedCodexProvider.value === 'openai') {\n return !codexOpenAIUseOwnKey.value || codexOpenAIKey.value.trim() !== ''\n }\n // 第三方 provider 必须有输入的 key 或已保存的 key\n const inputKey = codexOpenAIKey.value.trim()\n const hasSaved = !!savedProviderKeys.value[`codex:${selectedCodexProvider.value}`]\n return inputKey !== '' || hasSaved\n }\n if (platform === 'opencode') {\n if (selectedOpenCodeProvider.value === 'ccx') {\n return true\n }\n const inputKey = openCodeOpenAIKey.value.trim()\n const hasSaved = !!savedProviderKeys.value[`codex:${selectedOpenCodeProvider.value}`]\n return inputKey !== '' || hasSaved\n }\n if (selectedClaudeProvider.value === 'ccx') return true\n const provider = selectedClaudeProvider.value\n const inputKey = claudeProviderKeys.value[provider].trim()\n const planID = provider === 'mimo' ? selectedMimoPlan.value\n : provider === 'dashscope' ? selectedDashScopePlan.value\n : undefined\n const hasSaved = !!findSavedKey(provider, planID)\n return inputKey !== '' || hasSaved\n}\n\nconst applyAgent = async (platform: AgentPlatform) => {\n configLoading.value = true\n try {\n const request: ApplyAgentConfigRequest = { platform }\n if (platform === 'claude') {\n request.provider = selectedClaudeProvider.value\n if (selectedClaudeProvider.value !== 'ccx') {\n const inputKey = claudeProviderKeys.value[selectedClaudeProvider.value].trim()\n const planID = selectedClaudeProvider.value === 'mimo' ? selectedMimoPlan.value\n : selectedClaudeProvider.value === 'dashscope' ? selectedDashScopePlan.value\n : undefined\n request.apiKey = inputKey || findSavedKey(selectedClaudeProvider.value, planID)\n }\n if (selectedClaudeProvider.value === 'mimo') {\n request.baseUrl = claudeMimoBaseUrl.value.trim()\n }\n if (selectedClaudeProvider.value === 'dashscope') {\n request.baseUrl = selectedDashScopePlan.value.trim()\n }\n }\n if (platform === 'codex') {\n request.provider = selectedCodexProvider.value\n if (selectedCodexProvider.value === 'ccx' || isCodexThirdPartyWithMode(selectedCodexProvider.value)) {\n request.mode = codexMode.value\n }\n if (selectedCodexProvider.value === 'openai') {\n // OpenAI 直连只用用户当前输入的 key,不 fallback 到 saved key\n // 无 key → 后端走 OAuth 登录模式 (auth_mode=\"chatgpt\")\n request.apiKey = codexOpenAIKey.value.trim()\n } else if (selectedCodexProvider.value !== 'ccx') {\n const inputKey = codexOpenAIKey.value.trim()\n request.apiKey = inputKey || savedProviderKeys.value[`codex:${selectedCodexProvider.value}`] || ''\n }\n if (selectedCodexProvider.value === 'mimo') {\n request.baseUrl = selectedMimoCodexPlan.value.trim()\n }\n }\n if (platform === 'opencode') {\n request.provider = selectedOpenCodeProvider.value\n if (selectedOpenCodeProvider.value !== 'ccx') {\n const inputKey = openCodeOpenAIKey.value.trim()\n request.apiKey = inputKey || savedProviderKeys.value[`codex:${selectedOpenCodeProvider.value}`] || ''\n }\n if (selectedOpenCodeProvider.value === 'mimo') {\n request.baseUrl = selectedMimoOpenCodePlan.value.trim()\n }\n }\n await ApplyAgentConfig(request)\n await loadAgentStatuses()\n } finally {\n configLoading.value = false\n }\n}\n\nconst showApplyPreview = async (platform: AgentPlatform) => {\n const request: ApplyAgentConfigRequest = { platform }\n if (platform === 'claude') {\n request.provider = selectedClaudeProvider.value\n if (selectedClaudeProvider.value !== 'ccx') {\n const inputKey = claudeProviderKeys.value[selectedClaudeProvider.value].trim()\n request.apiKey = inputKey || findSavedKey(selectedClaudeProvider.value, selectedMimoPlan.value)\n }\n if (selectedClaudeProvider.value === 'mimo') {\n request.baseUrl = claudeMimoBaseUrl.value.trim()\n }\n if (selectedClaudeProvider.value === 'dashscope') {\n request.baseUrl = selectedDashScopePlan.value.trim()\n }\n }\n if (platform === 'codex') {\n request.provider = selectedCodexProvider.value\n if (selectedCodexProvider.value === 'ccx' || isCodexThirdPartyWithMode(selectedCodexProvider.value)) {\n request.mode = codexMode.value\n }\n if (selectedCodexProvider.value === 'openai') {\n // OpenAI 直连只用用户当前输入的 key,不 fallback 到 saved key\n request.apiKey = codexOpenAIKey.value.trim()\n } else if (selectedCodexProvider.value !== 'ccx') {\n const inputKey = codexOpenAIKey.value.trim()\n request.apiKey = inputKey || savedProviderKeys.value[`codex:${selectedCodexProvider.value}`] || ''\n }\n if (selectedCodexProvider.value === 'mimo') {\n request.baseUrl = selectedMimoCodexPlan.value.trim()\n }\n }\n if (platform === 'opencode') {\n request.provider = selectedOpenCodeProvider.value\n if (selectedOpenCodeProvider.value !== 'ccx') {\n const inputKey = openCodeOpenAIKey.value.trim()\n request.apiKey = inputKey || savedProviderKeys.value[`codex:${selectedOpenCodeProvider.value}`] || ''\n }\n if (selectedOpenCodeProvider.value === 'mimo') {\n request.baseUrl = selectedMimoOpenCodePlan.value.trim()\n }\n }\n // 检测模式切换,显示会话迁移警告\n diffWarning.value = undefined\n if (platform === 'codex' && (selectedCodexProvider.value === 'ccx' || isCodexThirdPartyWithMode(selectedCodexProvider.value))) {\n const currentMode = agentStatuses.value.codex?.mode === 'plugin' ? 'plugin' : 'quick'\n if (currentMode !== codexMode.value) {\n diffWarning.value = getT()('agent.sessionMigrationWarning')\n }\n }\n diffPendingPlatform.value = platform\n diffMode.value = 'apply'\n diffDialogOpen.value = true\n diffLoading.value = true\n diffResult.value = null\n try {\n diffResult.value = await PreviewAgentConfigDiff(request) as ConfigDiffResult\n } catch {\n diffResult.value = null\n } finally {\n diffLoading.value = false\n }\n}\n\nconst confirmApply = async () => {\n diffDialogOpen.value = false\n await applyAgent(diffPendingPlatform.value)\n}\n\nconst showRestorePreview = async (platform: AgentPlatform) => {\n diffPendingPlatform.value = platform\n diffMode.value = 'restore'\n diffDialogOpen.value = true\n diffLoading.value = true\n diffResult.value = null\n try {\n diffResult.value = await PreviewRestoreConfigDiff(platform) as ConfigDiffResult\n } catch {\n diffResult.value = null\n } finally {\n diffLoading.value = false\n }\n}\n\nconst confirmRestore = async () => {\n diffDialogOpen.value = false\n await restoreAgent(diffPendingPlatform.value)\n}\n\nconst closeDiffDialog = () => {\n diffDialogOpen.value = false\n}\n\nconst codexSessionTargetProvider = computed(() => {\n if (selectedCodexProvider.value === 'ccx') {\n return codexMode.value === 'plugin' ? 'ccx' : 'openai'\n }\n if (isCodexThirdPartyWithMode(selectedCodexProvider.value)) {\n return codexMode.value === 'plugin' ? selectedCodexProvider.value : 'openai'\n }\n return 'openai'\n})\n\nconst showMigrateDialog = () => {\n migrateResult.value = null\n migrateError.value = ''\n migrateDialogOpen.value = true\n}\n\nconst confirmMigrate = async () => {\n migrateLoading.value = true\n migrateError.value = ''\n migrateResult.value = null\n try {\n migrateResult.value = await MigrateCodexSessions({\n provider: selectedCodexProvider.value,\n mode: codexMode.value,\n }) as MigrateCodexSessionsResult\n } catch (error) {\n migrateError.value = error instanceof Error ? error.message : String(error)\n } finally {\n migrateLoading.value = false\n }\n}\n\nconst closeMigrateDialog = () => {\n if (migrateLoading.value) return\n migrateDialogOpen.value = false\n}\n\nconst restoreAgent = async (platform: AgentPlatform) => {\n configLoading.value = true\n try {\n await RestoreAgentConfig(platform)\n } finally {\n configLoading.value = false\n }\n}\n\nexport function useAgentConfig() {\n return {\n agentStatuses,\n configLoading,\n selectedClaudeProvider,\n claudeProviderKeys,\n savedProviderKeys,\n codexOpenAIKey,\n codexOpenAIUseOwnKey,\n codexMode,\n openCodeOpenAIKey,\n claudeMimoBaseUrl,\n selectedMimoPlan,\n selectedMimoCodexPlan,\n selectedMimoOpenCodePlan,\n selectedDashScopePlan,\n agentLabels,\n claudeProviderLabels,\n codexProviderLabels: getCodexProviderLabels(),\n agentPlatforms,\n isClaudeProvider,\n claudeProviderLabel,\n claudeTargetBaseUrl,\n agentStatusText,\n agentStatusClass,\n loadAgentStatuses,\n canApplyAgent,\n applyAgent,\n restoreAgent,\n selectedCodexProvider,\n selectedOpenCodeProvider,\n codexProviderLabel,\n codexTargetBaseUrl,\n openCodeProviderLabel,\n openCodeTargetBaseUrl,\n // Diff preview\n diffDialogOpen,\n diffResult,\n diffMode,\n diffLoading,\n diffPendingPlatform,\n diffWarning,\n showApplyPreview,\n showRestorePreview,\n confirmApply,\n confirmRestore,\n closeDiffDialog,\n // Codex session migration\n migrateDialogOpen,\n migrateLoading,\n migrateResult,\n migrateError,\n codexSessionTargetProvider,\n showMigrateDialog,\n confirmMigrate,\n closeMigrateDialog,\n }\n}\n"}, "e972d82e6b4b3edf23f948edbd0db2d4c80db655": {"t": 1785809885.3201826, "n": 23905, "h": "c1f3d0a7c64e511c", "c": "import { ref, computed, type ComputedRef } from 'vue'\nimport type { AgentPlatform, AgentProvider, AgentConfigStatus, ApplyAgentConfigRequest, ConfigDiffResult, MigrateCodexSessionsResult } from '@/types'\nimport { useLanguage } from '@/composables/useLanguage'\nimport {\n GetAgentConfigStatus,\n ApplyAgentConfig,\n RestoreAgentConfig,\n GetSavedProviderKeys,\n PreviewAgentConfigDiff,\n PreviewRestoreConfigDiff,\n MigrateCodexSessions,\n} from '@bindings/github.com/BenedictKing/ccx/desktop/desktopservice'\n\nconst agentLabels: Record = {\n claude: 'Claude Code',\n codex: 'Codex',\n opencode: 'OpenCode',\n}\n\nconst claudeProviderLabels: Record = {\n ccx: 'CCX',\n deepseek: 'DeepSeek',\n mimo: 'MiMo',\n compshare: 'Compshare',\n runapi: 'RunAPI',\n unity2: 'Unity2.ai',\n 'tencent-lkeap': '腾讯云 TokenHub',\n 'kimi-code': 'Kimi Code',\n 'volc-ark': '火山方舟',\n qianfan: '百度千帆',\n originrouter: '极易云',\n kimi: 'Kimi',\n glm: 'GLM',\n sensenova: 'SenseNova',\n minimax: 'MiniMax',\n dashscope: 'DashScope',\n openrouter: 'OpenRouter',\n modelscope: 'ModelScope',\n 'opencode-zen': 'OpenCode Zen',\n 'opencode-go': 'OpenCode Go',\n openai: 'OpenAI',\n xfyun: '讯飞星辰',\n // custom 需要 i18n,延迟到函数内获取\n custom: 'Custom',\n}\n\n// codexProviderLabels 需要响应式 t(),延迟到 useAgentConfig() 内构建\nlet _codexProviderLabels: ComputedRef> | null = null\n\nfunction getCodexProviderLabels(): ComputedRef> {\n if (!_codexProviderLabels) {\n const { t } = useLanguage()\n _codexProviderLabels = computed>(() => ({\n ccx: t('agent.localGateway'),\n openai: 'OpenAI',\n deepseek: 'DeepSeek',\n mimo: 'MiMo',\n compshare: 'Compshare',\n runapi: 'RunAPI',\n unity2: 'Unity2.ai',\n 'tencent-lkeap': '腾讯云 TokenHub',\n 'kimi-code': 'Kimi Code',\n 'volc-ark': '火山方舟',\n qianfan: '百度千帆',\n originrouter: '极易云',\n kimi: 'Kimi',\n glm: 'GLM',\n sensenova: 'SenseNova',\n minimax: 'MiniMax',\n dashscope: 'DashScope',\n openrouter: 'OpenRouter',\n modelscope: 'ModelScope',\n 'opencode-zen': 'OpenCode Zen',\n 'opencode-go': 'OpenCode Go',\n xfyun: '讯飞星辰',\n custom: t('agent.custom'),\n }))\n }\n return _codexProviderLabels!\n}\n\n// 延迟获取 t 函数,首次 useAgentConfig() 调用时初始化\nlet _t: ((key: string, params?: Record) => string) | null = null\n\nfunction getT() {\n if (!_t) {\n _t = useLanguage().t\n }\n return _t\n}\n\nconst agentPlatforms: AgentPlatform[] = ['claude', 'codex', 'opencode']\n\n// Module-level singletons\nconst agentStatuses = ref>({\n claude: null,\n codex: null,\n opencode: null,\n})\nconst configLoading = ref(false)\nconst selectedClaudeProvider = ref('ccx')\nconst claudeProviderKeys = ref>({\n ccx: '',\n deepseek: '',\n mimo: '',\n compshare: '',\n runapi: '',\n unity2: '',\n 'tencent-lkeap': '',\n 'kimi-code': '',\n 'volc-ark': '',\n qianfan: '',\n originrouter: '',\n kimi: '',\n glm: '',\n sensenova: '',\n minimax: '',\n dashscope: '',\n openrouter: '',\n modelscope: '',\n 'opencode-zen': '',\n 'opencode-go': '',\n openai: '',\n xfyun: '',\n})\nconst savedProviderKeys = ref>({})\nconst codexOpenAIKey = ref('')\n// OpenAI 直连:是否勾选「我有自己的 API Key」。勾选则必须输入 key,否则走 OAuth。\nconst codexOpenAIUseOwnKey = ref(false)\nconst openCodeOpenAIKey = ref('')\nconst claudeMimoBaseUrl = ref('https://api.xiaomimimo.com/anthropic')\nconst selectedMimoPlan = ref('https://api.xiaomimimo.com/anthropic')\nconst selectedMimoCodexPlan = ref('https://api.xiaomimimo.com/v1')\nconst selectedMimoOpenCodePlan = ref('https://api.xiaomimimo.com/v1')\nconst selectedDashScopePlan = ref('https://dashscope.aliyuncs.com/apps/anthropic')\nconst selectedCodexProvider = ref('ccx')\nconst codexMode = ref<'quick' | 'plugin'>('quick')\nconst selectedOpenCodeProvider = ref('ccx')\n\n// Diff preview dialog state\nconst diffDialogOpen = ref(false)\nconst diffResult = ref(null)\nconst diffMode = ref<'apply' | 'restore'>('apply')\nconst diffLoading = ref(false)\nconst diffPendingPlatform = ref('claude')\nconst diffWarning = ref(undefined)\nconst migrateDialogOpen = ref(false)\nconst migrateLoading = ref(false)\nconst migrateResult = ref(null)\nconst migrateError = ref('')\n\nconst isClaudeProvider = (value?: string): value is AgentProvider => {\n return value === 'ccx' || value === 'deepseek' || value === 'mimo' || value === 'compshare' || value === 'runapi' || value === 'unity2' || value === 'tencent-lkeap' || value === 'kimi' || value === 'glm' || value === 'sensenova' || value === 'minimax' || value === 'dashscope' || value === 'openrouter' || value === 'modelscope' || value === 'opencode-zen' || value === 'opencode-go' || value === 'xfyun' || value === 'volc-ark' || value === 'qianfan'\n}\n\n// Codex 支持快捷模式/插件模式切换的第三方 provider\nconst isCodexThirdPartyWithMode = (provider?: string) => {\n return provider === 'deepseek' || provider === 'mimo' || provider === 'compshare' || provider === 'runapi' || provider === 'unity2' || provider === 'kimi' || provider === 'glm' || provider === 'minimax' || provider === 'dashscope' || provider === 'openrouter' || provider === 'modelscope' || provider === 'opencode-zen' || provider === 'opencode-go' || provider === 'xfyun' || provider === 'tencent-lkeap' || provider === 'volc-ark' || provider === 'qianfan'\n}\n\nconst claudeProviderLabel = (value?: string) => {\n if (!value) return getT()('agent.statusDetecting')\n return claudeProviderLabels[value as AgentProvider | 'custom'] || value\n}\n\nconst codexProviderLabel = (value?: string) => {\n if (!value) return getT()('agent.statusDetecting')\n return getCodexProviderLabels().value[value as AgentProvider | 'custom'] || value\n}\n\nconst openCodeProviderLabel = (value?: string) => {\n if (!value) return getT()('agent.statusDetecting')\n return getCodexProviderLabels().value[value as AgentProvider | 'custom'] || value\n}\n\nconst claudeTargetBaseUrl = () => {\n switch (selectedClaudeProvider.value) {\n case 'ccx':\n return agentStatuses.value.claude?.targetBaseUrl || getT()('agent.localGateway')\n case 'deepseek':\n return 'https://api.deepseek.com/anthropic'\n case 'mimo':\n return claudeMimoBaseUrl.value || 'https://api.xiaomimimo.com/anthropic'\n case 'compshare':\n return 'https://cp.compshare.cn'\n case 'runapi':\n return 'https://runapi.co/v1'\n case 'unity2':\n return 'https://unity2.ai/v1'\n case 'kimi':\n return 'https://api.moonshot.cn/anthropic'\n case 'glm':\n return 'https://open.bigmodel.cn/api/anthropic'\n case 'sensenova':\n return 'https://token.sensenova.cn'\n case 'minimax':\n return 'https://api.minimaxi.com/anthropic'\n case 'dashscope':\n return selectedDashScopePlan.value\n case 'openrouter':\n return 'https://openrouter.ai/api'\n case 'modelscope':\n return 'https://api-inference.modelscope.cn'\n case 'opencode-zen':\n return 'https://opencode.ai/zen'\n case 'opencode-go':\n return 'https://opencode.ai/zen/go'\n case 'xfyun':\n return 'https://maas-coding-api.cn-huabei-1.xf-yun.com/anthropic'\n case 'tencent-lkeap':\n return 'https://api.lkeap.cloud.tencent.com/plan/anthropic'\n case 'volc-ark':\n return 'https://ark.cn-beijing.volces.com/api/coding'\n case 'qianfan':\n return 'https://qianfan.baidubce.com/anthropic/coding'\n default:\n return ''\n }\n}\n\nconst codexTargetBaseUrl = () => {\n switch (selectedCodexProvider.value) {\n case 'ccx':\n return agentStatuses.value.codex?.targetBaseUrl || getT()('agent.localGateway')\n case 'openai':\n return 'https://api.openai.com/v1'\n case 'deepseek':\n return 'https://api.deepseek.com/v1'\n case 'mimo':\n return selectedMimoCodexPlan.value || 'https://api.xiaomimimo.com/v1'\n case 'compshare':\n return 'https://cp.compshare.cn/v1'\n case 'kimi':\n return 'https://api.moonshot.cn/v1'\n case 'glm':\n return 'https://open.bigmodel.cn/api/paas/v4'\n case 'minimax':\n return 'https://api.minimax.chat/v1'\n case 'dashscope':\n return 'https://dashscope.aliyuncs.com/compatible-mode/v1'\n case 'runapi':\n return 'https://runapi.co/v1'\n case 'unity2':\n return 'https://unity2.ai/v1'\n case 'openrouter':\n return 'https://openrouter.ai/api/v1'\n case 'modelscope':\n return 'https://api-inference.modelscope.cn/v1'\n case 'opencode-zen':\n return 'https://opencode.ai/zen/v1'\n case 'opencode-go':\n return 'https://opencode.ai/zen/go/v1'\n case 'xfyun':\n return 'https://maas-coding-api.cn-huabei-1.xf-yun.com/v2'\n case 'tencent-lkeap':\n return 'https://api.lkeap.cloud.tencent.com/plan/v3'\n case 'volc-ark':\n return 'https://ark.cn-beijing.volces.com/api/coding/v3'\n case 'qianfan':\n return 'https://qianfan.baidubce.com/v2/coding'\n default:\n return ''\n }\n}\n\nconst openCodeTargetBaseUrl = () => {\n switch (selectedOpenCodeProvider.value) {\n case 'ccx':\n return agentStatuses.value.opencode?.targetBaseUrl || getT()('agent.localGateway')\n case 'deepseek':\n return 'https://api.deepseek.com/v1'\n case 'mimo':\n return selectedMimoOpenCodePlan.value || 'https://api.xiaomimimo.com/v1'\n case 'compshare':\n return 'https://cp.compshare.cn/v1'\n case 'kimi':\n return 'https://api.moonshot.cn/v1'\n case 'glm':\n return 'https://open.bigmodel.cn/api/paas/v4'\n case 'sensenova':\n return 'https://token.sensenova.cn/v1'\n case 'minimax':\n return 'https://api.minimax.chat/v1'\n case 'runapi':\n return 'https://runapi.co/v1'\n case 'unity2':\n return 'https://unity2.ai/v1'\n case 'dashscope':\n return 'https://dashscope.aliyuncs.com/compatible-mode/v1'\n case 'xfyun':\n return 'https://maas-coding-api.cn-huabei-1.xf-yun.com/v2'\n case 'openrouter':\n return 'https://openrouter.ai/api/v1'\n case 'modelscope':\n return 'https://api-inference.modelscope.cn/v1'\n case 'opencode-zen':\n return 'https://opencode.ai/zen/v1'\n case 'opencode-go':\n return 'https://opencode.ai/zen/go/v1'\n case 'tencent-lkeap':\n return 'https://api.lkeap.cloud.tencent.com/plan/v3'\n case 'volc-ark':\n return 'https://ark.cn-beijing.volces.com/api/coding/v3'\n case 'qianfan':\n return 'https://qianfan.baidubce.com/v2/coding'\n default:\n return ''\n }\n}\n\nconst agentStatusText = (item: AgentConfigStatus | null) => {\n const t = getT()\n if (!item) return t('agent.statusDetecting')\n if (item.configured) return t('agent.statusConfigured')\n if (item.needsUpdate) return t('agent.statusPortMismatch')\n return t('agent.statusUnconfigured')\n}\n\nconst agentStatusClass = (item: AgentConfigStatus | null) => {\n if (!item) return 'starting'\n if (item.configured) return 'running'\n if (item.needsUpdate) return 'starting'\n return 'stopped'\n}\n\nconst resolveMiMoPlan = (url: string): string => {\n const known = [\n 'https://api.xiaomimimo.com/anthropic',\n 'https://token-plan-cn.xiaomimimo.com/anthropic',\n 'https://token-plan-sgp.xiaomimimo.com/anthropic',\n 'https://token-plan-ams.xiaomimimo.com/anthropic',\n ]\n return known.includes(url) ? url : ''\n}\n\nconst resolveMiMoCodexPlan = (url: string): string => {\n const known = [\n 'https://api.xiaomimimo.com/v1',\n 'https://token-plan-cn.xiaomimimo.com/v1',\n 'https://token-plan-sgp.xiaomimimo.com/v1',\n 'https://token-plan-ams.xiaomimimo.com/v1',\n ]\n return known.includes(url) ? url : ''\n}\n\nconst resolveDashScopePlan = (url: string): string => {\n const known = [\n 'https://dashscope.aliyuncs.com/apps/anthropic',\n 'https://coding.dashscope.aliyuncs.com/apps/anthropic',\n ]\n return known.includes(url) ? url : ''\n}\n\nconst loadAgentStatuses = async () => {\n configLoading.value = true\n try {\n const [claude, codex, opencode, keys] = await Promise.all([\n GetAgentConfigStatus('claude') as Promise,\n GetAgentConfigStatus('codex') as Promise,\n GetAgentConfigStatus('opencode') as Promise,\n GetSavedProviderKeys(),\n ])\n agentStatuses.value = { claude, codex, opencode }\n savedProviderKeys.value = Object.fromEntries(\n Object.entries(keys).filter((entry): entry is [string, string] => typeof entry[1] === 'string')\n )\n if (isClaudeProvider(claude.provider)) {\n selectedClaudeProvider.value = claude.provider\n }\n if (claude.provider === 'mimo' && claude.currentBaseUrl) {\n claudeMimoBaseUrl.value = claude.currentBaseUrl\n selectedMimoPlan.value = resolveMiMoPlan(claude.currentBaseUrl)\n }\n if (claude.provider === 'dashscope' && claude.currentBaseUrl) {\n selectedDashScopePlan.value = resolveDashScopePlan(claude.currentBaseUrl)\n }\n if (codex.provider && codex.provider !== 'ccx' && codex.provider !== '') {\n selectedCodexProvider.value = codex.provider as AgentProvider\n } else {\n selectedCodexProvider.value = 'ccx'\n }\n if (codex.provider === 'mimo' && codex.currentBaseUrl) {\n selectedMimoCodexPlan.value = resolveMiMoCodexPlan(codex.currentBaseUrl) || 'https://api.xiaomimimo.com/v1'\n }\n // 恢复所有支持 mode 切换的 provider 的 mode 状态\n if (codex.provider === 'ccx' || codex.provider === 'dashscope' || codex.provider === 'runapi' || codex.provider === 'opencode-zen' || codex.provider === 'opencode-go') {\n codexMode.value = codex.mode === 'plugin' ? 'plugin' : 'quick'\n }\n if (opencode.provider && opencode.provider !== 'ccx' && opencode.provider !== '') {\n selectedOpenCodeProvider.value = opencode.provider as AgentProvider\n } else {\n selectedOpenCodeProvider.value = 'ccx'\n }\n if (opencode.provider === 'mimo' && opencode.currentBaseUrl) {\n selectedMimoOpenCodePlan.value = resolveMiMoCodexPlan(opencode.currentBaseUrl) || 'https://api.xiaomimimo.com/v1'\n }\n } catch (error) {\n // error is handled by caller\n } finally {\n configLoading.value = false\n }\n}\n\nconst findSavedKey = (provider: string, planID?: string): string => {\n if (planID) {\n const planKey = savedProviderKeys.value[`claude:${provider}:${planID}`]\n if (planKey) return planKey\n }\n return savedProviderKeys.value[`claude:${provider}`] || ''\n}\n\nconst canApplyAgent = (platform: AgentPlatform) => {\n if (configLoading.value) return false\n if (platform === 'codex') {\n // CCX 用 proxy key,无需验证\n if (selectedCodexProvider.value === 'ccx') {\n return true\n }\n // OpenAI 直连:不勾选走 OAuth;勾选「我有自己的 API Key」则必须输入\n if (selectedCodexProvider.value === 'openai') {\n return !codexOpenAIUseOwnKey.value || codexOpenAIKey.value.trim() !== ''\n }\n // 第三方 provider 必须有输入的 key 或已保存的 key\n const inputKey = codexOpenAIKey.value.trim()\n const hasSaved = !!savedProviderKeys.value[`codex:${selectedCodexProvider.value}`]\n return inputKey !== '' || hasSaved\n }\n if (platform === 'opencode') {\n if (selectedOpenCodeProvider.value === 'ccx') {\n return true\n }\n const inputKey = openCodeOpenAIKey.value.trim()\n const hasSaved = !!savedProviderKeys.value[`codex:${selectedOpenCodeProvider.value}`]\n return inputKey !== '' || hasSaved\n }\n if (selectedClaudeProvider.value === 'ccx') return true\n const provider = selectedClaudeProvider.value\n const inputKey = claudeProviderKeys.value[provider].trim()\n const planID = provider === 'mimo' ? selectedMimoPlan.value\n : provider === 'dashscope' ? selectedDashScopePlan.value\n : undefined\n const hasSaved = !!findSavedKey(provider, planID)\n return inputKey !== '' || hasSaved\n}\n\nconst applyAgent = async (platform: AgentPlatform) => {\n configLoading.value = true\n try {\n const request: ApplyAgentConfigRequest = { platform }\n if (platform === 'claude') {\n request.provider = selectedClaudeProvider.value\n if (selectedClaudeProvider.value !== 'ccx') {\n const inputKey = claudeProviderKeys.value[selectedClaudeProvider.value].trim()\n const planID = selectedClaudeProvider.value === 'mimo' ? selectedMimoPlan.value\n : selectedClaudeProvider.value === 'dashscope' ? selectedDashScopePlan.value\n : undefined\n request.apiKey = inputKey || findSavedKey(selectedClaudeProvider.value, planID)\n }\n if (selectedClaudeProvider.value === 'mimo') {\n request.baseUrl = claudeMimoBaseUrl.value.trim()\n }\n if (selectedClaudeProvider.value === 'dashscope') {\n request.baseUrl = selectedDashScopePlan.value.trim()\n }\n }\n if (platform === 'codex') {\n request.provider = selectedCodexProvider.value\n if (selectedCodexProvider.value === 'ccx' || isCodexThirdPartyWithMode(selectedCodexProvider.value)) {\n request.mode = codexMode.value\n }\n if (selectedCodexProvider.value === 'openai') {\n // OpenAI 直连只用用户当前输入的 key,不 fallback 到 saved key\n // 无 key → 后端走 OAuth 登录模式 (auth_mode=\"chatgpt\")\n request.apiKey = codexOpenAIKey.value.trim()\n } else if (selectedCodexProvider.value !== 'ccx') {\n const inputKey = codexOpenAIKey.value.trim()\n request.apiKey = inputKey || savedProviderKeys.value[`codex:${selectedCodexProvider.value}`] || ''\n }\n if (selectedCodexProvider.value === 'mimo') {\n request.baseUrl = selectedMimoCodexPlan.value.trim()\n }\n }\n if (platform === 'opencode') {\n request.provider = selectedOpenCodeProvider.value\n if (selectedOpenCodeProvider.value !== 'ccx') {\n const inputKey = openCodeOpenAIKey.value.trim()\n request.apiKey = inputKey || savedProviderKeys.value[`codex:${selectedOpenCodeProvider.value}`] || ''\n }\n if (selectedOpenCodeProvider.value === 'mimo') {\n request.baseUrl = selectedMimoOpenCodePlan.value.trim()\n }\n }\n await ApplyAgentConfig(request)\n await loadAgentStatuses()\n } finally {\n configLoading.value = false\n }\n}\n\nconst showApplyPreview = async (platform: AgentPlatform) => {\n const request: ApplyAgentConfigRequest = { platform }\n if (platform === 'claude') {\n request.provider = selectedClaudeProvider.value\n if (selectedClaudeProvider.value !== 'ccx') {\n const inputKey = claudeProviderKeys.value[selectedClaudeProvider.value].trim()\n request.apiKey = inputKey || findSavedKey(selectedClaudeProvider.value, selectedMimoPlan.value)\n }\n if (selectedClaudeProvider.value === 'mimo') {\n request.baseUrl = claudeMimoBaseUrl.value.trim()\n }\n if (selectedClaudeProvider.value === 'dashscope') {\n request.baseUrl = selectedDashScopePlan.value.trim()\n }\n }\n if (platform === 'codex') {\n request.provider = selectedCodexProvider.value\n if (selectedCodexProvider.value === 'ccx' || isCodexThirdPartyWithMode(selectedCodexProvider.value)) {\n request.mode = codexMode.value\n }\n if (selectedCodexProvider.value === 'openai') {\n // OpenAI 直连只用用户当前输入的 key,不 fallback 到 saved key\n request.apiKey = codexOpenAIKey.value.trim()\n } else if (selectedCodexProvider.value !== 'ccx') {\n const inputKey = codexOpenAIKey.value.trim()\n request.apiKey = inputKey || savedProviderKeys.value[`codex:${selectedCodexProvider.value}`] || ''\n }\n if (selectedCodexProvider.value === 'mimo') {\n request.baseUrl = selectedMimoCodexPlan.value.trim()\n }\n }\n if (platform === 'opencode') {\n request.provider = selectedOpenCodeProvider.value\n if (selectedOpenCodeProvider.value !== 'ccx') {\n const inputKey = openCodeOpenAIKey.value.trim()\n request.apiKey = inputKey || savedProviderKeys.value[`codex:${selectedOpenCodeProvider.value}`] || ''\n }\n if (selectedOpenCodeProvider.value === 'mimo') {\n request.baseUrl = selectedMimoOpenCodePlan.value.trim()\n }\n }\n // 检测模式切换,显示会话迁移警告\n diffWarning.value = undefined\n if (platform === 'codex' && (selectedCodexProvider.value === 'ccx' || isCodexThirdPartyWithMode(selectedCodexProvider.value))) {\n const currentMode = agentStatuses.value.codex?.mode === 'plugin' ? 'plugin' : 'quick'\n if (currentMode !== codexMode.value) {\n diffWarning.value = getT()('agent.sessionMigrationWarning')\n }\n }\n diffPendingPlatform.value = platform\n diffMode.value = 'apply'\n diffDialogOpen.value = true\n diffLoading.value = true\n diffResult.value = null\n try {\n diffResult.value = await PreviewAgentConfigDiff(request) as ConfigDiffResult\n } catch {\n diffResult.value = null\n } finally {\n diffLoading.value = false\n }\n}\n\nconst confirmApply = async () => {\n diffDialogOpen.value = false\n await applyAgent(diffPendingPlatform.value)\n}\n\nconst showRestorePreview = async (platform: AgentPlatform) => {\n diffPendingPlatform.value = platform\n diffMode.value = 'restore'\n diffDialogOpen.value = true\n diffLoading.value = true\n diffResult.value = null\n try {\n diffResult.value = await PreviewRestoreConfigDiff(platform) as ConfigDiffResult\n } catch {\n diffResult.value = null\n } finally {\n diffLoading.value = false\n }\n}\n\nconst confirmRestore = async () => {\n diffDialogOpen.value = false\n await restoreAgent(diffPendingPlatform.value)\n}\n\nconst closeDiffDialog = () => {\n diffDialogOpen.value = false\n}\n\nconst codexSessionTargetProvider = computed(() => {\n if (selectedCodexProvider.value === 'ccx') {\n return codexMode.value === 'plugin' ? 'ccx' : 'openai'\n }\n if (isCodexThirdPartyWithMode(selectedCodexProvider.value)) {\n return codexMode.value === 'plugin' ? selectedCodexProvider.value : 'openai'\n }\n return 'openai'\n})\n\nconst showMigrateDialog = () => {\n migrateResult.value = null\n migrateError.value = ''\n migrateDialogOpen.value = true\n}\n\nconst confirmMigrate = async () => {\n migrateLoading.value = true\n migrateError.value = ''\n migrateResult.value = null\n try {\n migrateResult.value = await MigrateCodexSessions({\n provider: selectedCodexProvider.value,\n mode: codexMode.value,\n }) as MigrateCodexSessionsResult\n } catch (error) {\n migrateError.value = error instanceof Error ? error.message : String(error)\n } finally {\n migrateLoading.value = false\n }\n}\n\nconst closeMigrateDialog = () => {\n if (migrateLoading.value) return\n migrateDialogOpen.value = false\n}\n\nconst restoreAgent = async (platform: AgentPlatform) => {\n configLoading.value = true\n try {\n await RestoreAgentConfig(platform)\n } finally {\n configLoading.value = false\n }\n}\n\nexport function useAgentConfig() {\n return {\n agentStatuses,\n configLoading,\n selectedClaudeProvider,\n claudeProviderKeys,\n savedProviderKeys,\n codexOpenAIKey,\n codexOpenAIUseOwnKey,\n codexMode,\n openCodeOpenAIKey,\n claudeMimoBaseUrl,\n selectedMimoPlan,\n selectedMimoCodexPlan,\n selectedMimoOpenCodePlan,\n selectedDashScopePlan,\n agentLabels,\n claudeProviderLabels,\n codexProviderLabels: getCodexProviderLabels(),\n agentPlatforms,\n isClaudeProvider,\n claudeProviderLabel,\n claudeTargetBaseUrl,\n agentStatusText,\n agentStatusClass,\n loadAgentStatuses,\n canApplyAgent,\n applyAgent,\n restoreAgent,\n selectedCodexProvider,\n selectedOpenCodeProvider,\n codexProviderLabel,\n codexTargetBaseUrl,\n openCodeProviderLabel,\n openCodeTargetBaseUrl,\n // Diff preview\n diffDialogOpen,\n diffResult,\n diffMode,\n diffLoading,\n diffPendingPlatform,\n diffWarning,\n showApplyPreview,\n showRestorePreview,\n confirmApply,\n confirmRestore,\n closeDiffDialog,\n // Codex session migration\n migrateDialogOpen,\n migrateLoading,\n migrateResult,\n migrateError,\n codexSessionTargetProvider,\n showMigrateDialog,\n confirmMigrate,\n closeMigrateDialog,\n }\n}\n"}, "4da6d77e15a5c2ac4bc15ce1ad1a68cfdbd2e2d0": {"t": 1785846961.1390479, "n": 23905, "h": "c1f3d0a7c64e511c", "c": "import { ref, computed, type ComputedRef } from 'vue'\nimport type { AgentPlatform, AgentProvider, AgentConfigStatus, ApplyAgentConfigRequest, ConfigDiffResult, MigrateCodexSessionsResult } from '@/types'\nimport { useLanguage } from '@/composables/useLanguage'\nimport {\n GetAgentConfigStatus,\n ApplyAgentConfig,\n RestoreAgentConfig,\n GetSavedProviderKeys,\n PreviewAgentConfigDiff,\n PreviewRestoreConfigDiff,\n MigrateCodexSessions,\n} from '@bindings/github.com/BenedictKing/ccx/desktop/desktopservice'\n\nconst agentLabels: Record = {\n claude: 'Claude Code',\n codex: 'Codex',\n opencode: 'OpenCode',\n}\n\nconst claudeProviderLabels: Record = {\n ccx: 'CCX',\n deepseek: 'DeepSeek',\n mimo: 'MiMo',\n compshare: 'Compshare',\n runapi: 'RunAPI',\n unity2: 'Unity2.ai',\n 'tencent-lkeap': '腾讯云 TokenHub',\n 'kimi-code': 'Kimi Code',\n 'volc-ark': '火山方舟',\n qianfan: '百度千帆',\n originrouter: '极易云',\n kimi: 'Kimi',\n glm: 'GLM',\n sensenova: 'SenseNova',\n minimax: 'MiniMax',\n dashscope: 'DashScope',\n openrouter: 'OpenRouter',\n modelscope: 'ModelScope',\n 'opencode-zen': 'OpenCode Zen',\n 'opencode-go': 'OpenCode Go',\n openai: 'OpenAI',\n xfyun: '讯飞星辰',\n // custom 需要 i18n,延迟到函数内获取\n custom: 'Custom',\n}\n\n// codexProviderLabels 需要响应式 t(),延迟到 useAgentConfig() 内构建\nlet _codexProviderLabels: ComputedRef> | null = null\n\nfunction getCodexProviderLabels(): ComputedRef> {\n if (!_codexProviderLabels) {\n const { t } = useLanguage()\n _codexProviderLabels = computed>(() => ({\n ccx: t('agent.localGateway'),\n openai: 'OpenAI',\n deepseek: 'DeepSeek',\n mimo: 'MiMo',\n compshare: 'Compshare',\n runapi: 'RunAPI',\n unity2: 'Unity2.ai',\n 'tencent-lkeap': '腾讯云 TokenHub',\n 'kimi-code': 'Kimi Code',\n 'volc-ark': '火山方舟',\n qianfan: '百度千帆',\n originrouter: '极易云',\n kimi: 'Kimi',\n glm: 'GLM',\n sensenova: 'SenseNova',\n minimax: 'MiniMax',\n dashscope: 'DashScope',\n openrouter: 'OpenRouter',\n modelscope: 'ModelScope',\n 'opencode-zen': 'OpenCode Zen',\n 'opencode-go': 'OpenCode Go',\n xfyun: '讯飞星辰',\n custom: t('agent.custom'),\n }))\n }\n return _codexProviderLabels!\n}\n\n// 延迟获取 t 函数,首次 useAgentConfig() 调用时初始化\nlet _t: ((key: string, params?: Record) => string) | null = null\n\nfunction getT() {\n if (!_t) {\n _t = useLanguage().t\n }\n return _t\n}\n\nconst agentPlatforms: AgentPlatform[] = ['claude', 'codex', 'opencode']\n\n// Module-level singletons\nconst agentStatuses = ref>({\n claude: null,\n codex: null,\n opencode: null,\n})\nconst configLoading = ref(false)\nconst selectedClaudeProvider = ref('ccx')\nconst claudeProviderKeys = ref>({\n ccx: '',\n deepseek: '',\n mimo: '',\n compshare: '',\n runapi: '',\n unity2: '',\n 'tencent-lkeap': '',\n 'kimi-code': '',\n 'volc-ark': '',\n qianfan: '',\n originrouter: '',\n kimi: '',\n glm: '',\n sensenova: '',\n minimax: '',\n dashscope: '',\n openrouter: '',\n modelscope: '',\n 'opencode-zen': '',\n 'opencode-go': '',\n openai: '',\n xfyun: '',\n})\nconst savedProviderKeys = ref>({})\nconst codexOpenAIKey = ref('')\n// OpenAI 直连:是否勾选「我有自己的 API Key」。勾选则必须输入 key,否则走 OAuth。\nconst codexOpenAIUseOwnKey = ref(false)\nconst openCodeOpenAIKey = ref('')\nconst claudeMimoBaseUrl = ref('https://api.xiaomimimo.com/anthropic')\nconst selectedMimoPlan = ref('https://api.xiaomimimo.com/anthropic')\nconst selectedMimoCodexPlan = ref('https://api.xiaomimimo.com/v1')\nconst selectedMimoOpenCodePlan = ref('https://api.xiaomimimo.com/v1')\nconst selectedDashScopePlan = ref('https://dashscope.aliyuncs.com/apps/anthropic')\nconst selectedCodexProvider = ref('ccx')\nconst codexMode = ref<'quick' | 'plugin'>('quick')\nconst selectedOpenCodeProvider = ref('ccx')\n\n// Diff preview dialog state\nconst diffDialogOpen = ref(false)\nconst diffResult = ref(null)\nconst diffMode = ref<'apply' | 'restore'>('apply')\nconst diffLoading = ref(false)\nconst diffPendingPlatform = ref('claude')\nconst diffWarning = ref(undefined)\nconst migrateDialogOpen = ref(false)\nconst migrateLoading = ref(false)\nconst migrateResult = ref(null)\nconst migrateError = ref('')\n\nconst isClaudeProvider = (value?: string): value is AgentProvider => {\n return value === 'ccx' || value === 'deepseek' || value === 'mimo' || value === 'compshare' || value === 'runapi' || value === 'unity2' || value === 'tencent-lkeap' || value === 'kimi' || value === 'glm' || value === 'sensenova' || value === 'minimax' || value === 'dashscope' || value === 'openrouter' || value === 'modelscope' || value === 'opencode-zen' || value === 'opencode-go' || value === 'xfyun' || value === 'volc-ark' || value === 'qianfan'\n}\n\n// Codex 支持快捷模式/插件模式切换的第三方 provider\nconst isCodexThirdPartyWithMode = (provider?: string) => {\n return provider === 'deepseek' || provider === 'mimo' || provider === 'compshare' || provider === 'runapi' || provider === 'unity2' || provider === 'kimi' || provider === 'glm' || provider === 'minimax' || provider === 'dashscope' || provider === 'openrouter' || provider === 'modelscope' || provider === 'opencode-zen' || provider === 'opencode-go' || provider === 'xfyun' || provider === 'tencent-lkeap' || provider === 'volc-ark' || provider === 'qianfan'\n}\n\nconst claudeProviderLabel = (value?: string) => {\n if (!value) return getT()('agent.statusDetecting')\n return claudeProviderLabels[value as AgentProvider | 'custom'] || value\n}\n\nconst codexProviderLabel = (value?: string) => {\n if (!value) return getT()('agent.statusDetecting')\n return getCodexProviderLabels().value[value as AgentProvider | 'custom'] || value\n}\n\nconst openCodeProviderLabel = (value?: string) => {\n if (!value) return getT()('agent.statusDetecting')\n return getCodexProviderLabels().value[value as AgentProvider | 'custom'] || value\n}\n\nconst claudeTargetBaseUrl = () => {\n switch (selectedClaudeProvider.value) {\n case 'ccx':\n return agentStatuses.value.claude?.targetBaseUrl || getT()('agent.localGateway')\n case 'deepseek':\n return 'https://api.deepseek.com/anthropic'\n case 'mimo':\n return claudeMimoBaseUrl.value || 'https://api.xiaomimimo.com/anthropic'\n case 'compshare':\n return 'https://cp.compshare.cn'\n case 'runapi':\n return 'https://runapi.co/v1'\n case 'unity2':\n return 'https://unity2.ai/v1'\n case 'kimi':\n return 'https://api.moonshot.cn/anthropic'\n case 'glm':\n return 'https://open.bigmodel.cn/api/anthropic'\n case 'sensenova':\n return 'https://token.sensenova.cn'\n case 'minimax':\n return 'https://api.minimaxi.com/anthropic'\n case 'dashscope':\n return selectedDashScopePlan.value\n case 'openrouter':\n return 'https://openrouter.ai/api'\n case 'modelscope':\n return 'https://api-inference.modelscope.cn'\n case 'opencode-zen':\n return 'https://opencode.ai/zen'\n case 'opencode-go':\n return 'https://opencode.ai/zen/go'\n case 'xfyun':\n return 'https://maas-coding-api.cn-huabei-1.xf-yun.com/anthropic'\n case 'tencent-lkeap':\n return 'https://api.lkeap.cloud.tencent.com/plan/anthropic'\n case 'volc-ark':\n return 'https://ark.cn-beijing.volces.com/api/coding'\n case 'qianfan':\n return 'https://qianfan.baidubce.com/anthropic/coding'\n default:\n return ''\n }\n}\n\nconst codexTargetBaseUrl = () => {\n switch (selectedCodexProvider.value) {\n case 'ccx':\n return agentStatuses.value.codex?.targetBaseUrl || getT()('agent.localGateway')\n case 'openai':\n return 'https://api.openai.com/v1'\n case 'deepseek':\n return 'https://api.deepseek.com/v1'\n case 'mimo':\n return selectedMimoCodexPlan.value || 'https://api.xiaomimimo.com/v1'\n case 'compshare':\n return 'https://cp.compshare.cn/v1'\n case 'kimi':\n return 'https://api.moonshot.cn/v1'\n case 'glm':\n return 'https://open.bigmodel.cn/api/paas/v4'\n case 'minimax':\n return 'https://api.minimax.chat/v1'\n case 'dashscope':\n return 'https://dashscope.aliyuncs.com/compatible-mode/v1'\n case 'runapi':\n return 'https://runapi.co/v1'\n case 'unity2':\n return 'https://unity2.ai/v1'\n case 'openrouter':\n return 'https://openrouter.ai/api/v1'\n case 'modelscope':\n return 'https://api-inference.modelscope.cn/v1'\n case 'opencode-zen':\n return 'https://opencode.ai/zen/v1'\n case 'opencode-go':\n return 'https://opencode.ai/zen/go/v1'\n case 'xfyun':\n return 'https://maas-coding-api.cn-huabei-1.xf-yun.com/v2'\n case 'tencent-lkeap':\n return 'https://api.lkeap.cloud.tencent.com/plan/v3'\n case 'volc-ark':\n return 'https://ark.cn-beijing.volces.com/api/coding/v3'\n case 'qianfan':\n return 'https://qianfan.baidubce.com/v2/coding'\n default:\n return ''\n }\n}\n\nconst openCodeTargetBaseUrl = () => {\n switch (selectedOpenCodeProvider.value) {\n case 'ccx':\n return agentStatuses.value.opencode?.targetBaseUrl || getT()('agent.localGateway')\n case 'deepseek':\n return 'https://api.deepseek.com/v1'\n case 'mimo':\n return selectedMimoOpenCodePlan.value || 'https://api.xiaomimimo.com/v1'\n case 'compshare':\n return 'https://cp.compshare.cn/v1'\n case 'kimi':\n return 'https://api.moonshot.cn/v1'\n case 'glm':\n return 'https://open.bigmodel.cn/api/paas/v4'\n case 'sensenova':\n return 'https://token.sensenova.cn/v1'\n case 'minimax':\n return 'https://api.minimax.chat/v1'\n case 'runapi':\n return 'https://runapi.co/v1'\n case 'unity2':\n return 'https://unity2.ai/v1'\n case 'dashscope':\n return 'https://dashscope.aliyuncs.com/compatible-mode/v1'\n case 'xfyun':\n return 'https://maas-coding-api.cn-huabei-1.xf-yun.com/v2'\n case 'openrouter':\n return 'https://openrouter.ai/api/v1'\n case 'modelscope':\n return 'https://api-inference.modelscope.cn/v1'\n case 'opencode-zen':\n return 'https://opencode.ai/zen/v1'\n case 'opencode-go':\n return 'https://opencode.ai/zen/go/v1'\n case 'tencent-lkeap':\n return 'https://api.lkeap.cloud.tencent.com/plan/v3'\n case 'volc-ark':\n return 'https://ark.cn-beijing.volces.com/api/coding/v3'\n case 'qianfan':\n return 'https://qianfan.baidubce.com/v2/coding'\n default:\n return ''\n }\n}\n\nconst agentStatusText = (item: AgentConfigStatus | null) => {\n const t = getT()\n if (!item) return t('agent.statusDetecting')\n if (item.configured) return t('agent.statusConfigured')\n if (item.needsUpdate) return t('agent.statusPortMismatch')\n return t('agent.statusUnconfigured')\n}\n\nconst agentStatusClass = (item: AgentConfigStatus | null) => {\n if (!item) return 'starting'\n if (item.configured) return 'running'\n if (item.needsUpdate) return 'starting'\n return 'stopped'\n}\n\nconst resolveMiMoPlan = (url: string): string => {\n const known = [\n 'https://api.xiaomimimo.com/anthropic',\n 'https://token-plan-cn.xiaomimimo.com/anthropic',\n 'https://token-plan-sgp.xiaomimimo.com/anthropic',\n 'https://token-plan-ams.xiaomimimo.com/anthropic',\n ]\n return known.includes(url) ? url : ''\n}\n\nconst resolveMiMoCodexPlan = (url: string): string => {\n const known = [\n 'https://api.xiaomimimo.com/v1',\n 'https://token-plan-cn.xiaomimimo.com/v1',\n 'https://token-plan-sgp.xiaomimimo.com/v1',\n 'https://token-plan-ams.xiaomimimo.com/v1',\n ]\n return known.includes(url) ? url : ''\n}\n\nconst resolveDashScopePlan = (url: string): string => {\n const known = [\n 'https://dashscope.aliyuncs.com/apps/anthropic',\n 'https://coding.dashscope.aliyuncs.com/apps/anthropic',\n ]\n return known.includes(url) ? url : ''\n}\n\nconst loadAgentStatuses = async () => {\n configLoading.value = true\n try {\n const [claude, codex, opencode, keys] = await Promise.all([\n GetAgentConfigStatus('claude') as Promise,\n GetAgentConfigStatus('codex') as Promise,\n GetAgentConfigStatus('opencode') as Promise,\n GetSavedProviderKeys(),\n ])\n agentStatuses.value = { claude, codex, opencode }\n savedProviderKeys.value = Object.fromEntries(\n Object.entries(keys).filter((entry): entry is [string, string] => typeof entry[1] === 'string')\n )\n if (isClaudeProvider(claude.provider)) {\n selectedClaudeProvider.value = claude.provider\n }\n if (claude.provider === 'mimo' && claude.currentBaseUrl) {\n claudeMimoBaseUrl.value = claude.currentBaseUrl\n selectedMimoPlan.value = resolveMiMoPlan(claude.currentBaseUrl)\n }\n if (claude.provider === 'dashscope' && claude.currentBaseUrl) {\n selectedDashScopePlan.value = resolveDashScopePlan(claude.currentBaseUrl)\n }\n if (codex.provider && codex.provider !== 'ccx' && codex.provider !== '') {\n selectedCodexProvider.value = codex.provider as AgentProvider\n } else {\n selectedCodexProvider.value = 'ccx'\n }\n if (codex.provider === 'mimo' && codex.currentBaseUrl) {\n selectedMimoCodexPlan.value = resolveMiMoCodexPlan(codex.currentBaseUrl) || 'https://api.xiaomimimo.com/v1'\n }\n // 恢复所有支持 mode 切换的 provider 的 mode 状态\n if (codex.provider === 'ccx' || codex.provider === 'dashscope' || codex.provider === 'runapi' || codex.provider === 'opencode-zen' || codex.provider === 'opencode-go') {\n codexMode.value = codex.mode === 'plugin' ? 'plugin' : 'quick'\n }\n if (opencode.provider && opencode.provider !== 'ccx' && opencode.provider !== '') {\n selectedOpenCodeProvider.value = opencode.provider as AgentProvider\n } else {\n selectedOpenCodeProvider.value = 'ccx'\n }\n if (opencode.provider === 'mimo' && opencode.currentBaseUrl) {\n selectedMimoOpenCodePlan.value = resolveMiMoCodexPlan(opencode.currentBaseUrl) || 'https://api.xiaomimimo.com/v1'\n }\n } catch (error) {\n // error is handled by caller\n } finally {\n configLoading.value = false\n }\n}\n\nconst findSavedKey = (provider: string, planID?: string): string => {\n if (planID) {\n const planKey = savedProviderKeys.value[`claude:${provider}:${planID}`]\n if (planKey) return planKey\n }\n return savedProviderKeys.value[`claude:${provider}`] || ''\n}\n\nconst canApplyAgent = (platform: AgentPlatform) => {\n if (configLoading.value) return false\n if (platform === 'codex') {\n // CCX 用 proxy key,无需验证\n if (selectedCodexProvider.value === 'ccx') {\n return true\n }\n // OpenAI 直连:不勾选走 OAuth;勾选「我有自己的 API Key」则必须输入\n if (selectedCodexProvider.value === 'openai') {\n return !codexOpenAIUseOwnKey.value || codexOpenAIKey.value.trim() !== ''\n }\n // 第三方 provider 必须有输入的 key 或已保存的 key\n const inputKey = codexOpenAIKey.value.trim()\n const hasSaved = !!savedProviderKeys.value[`codex:${selectedCodexProvider.value}`]\n return inputKey !== '' || hasSaved\n }\n if (platform === 'opencode') {\n if (selectedOpenCodeProvider.value === 'ccx') {\n return true\n }\n const inputKey = openCodeOpenAIKey.value.trim()\n const hasSaved = !!savedProviderKeys.value[`codex:${selectedOpenCodeProvider.value}`]\n return inputKey !== '' || hasSaved\n }\n if (selectedClaudeProvider.value === 'ccx') return true\n const provider = selectedClaudeProvider.value\n const inputKey = claudeProviderKeys.value[provider].trim()\n const planID = provider === 'mimo' ? selectedMimoPlan.value\n : provider === 'dashscope' ? selectedDashScopePlan.value\n : undefined\n const hasSaved = !!findSavedKey(provider, planID)\n return inputKey !== '' || hasSaved\n}\n\nconst applyAgent = async (platform: AgentPlatform) => {\n configLoading.value = true\n try {\n const request: ApplyAgentConfigRequest = { platform }\n if (platform === 'claude') {\n request.provider = selectedClaudeProvider.value\n if (selectedClaudeProvider.value !== 'ccx') {\n const inputKey = claudeProviderKeys.value[selectedClaudeProvider.value].trim()\n const planID = selectedClaudeProvider.value === 'mimo' ? selectedMimoPlan.value\n : selectedClaudeProvider.value === 'dashscope' ? selectedDashScopePlan.value\n : undefined\n request.apiKey = inputKey || findSavedKey(selectedClaudeProvider.value, planID)\n }\n if (selectedClaudeProvider.value === 'mimo') {\n request.baseUrl = claudeMimoBaseUrl.value.trim()\n }\n if (selectedClaudeProvider.value === 'dashscope') {\n request.baseUrl = selectedDashScopePlan.value.trim()\n }\n }\n if (platform === 'codex') {\n request.provider = selectedCodexProvider.value\n if (selectedCodexProvider.value === 'ccx' || isCodexThirdPartyWithMode(selectedCodexProvider.value)) {\n request.mode = codexMode.value\n }\n if (selectedCodexProvider.value === 'openai') {\n // OpenAI 直连只用用户当前输入的 key,不 fallback 到 saved key\n // 无 key → 后端走 OAuth 登录模式 (auth_mode=\"chatgpt\")\n request.apiKey = codexOpenAIKey.value.trim()\n } else if (selectedCodexProvider.value !== 'ccx') {\n const inputKey = codexOpenAIKey.value.trim()\n request.apiKey = inputKey || savedProviderKeys.value[`codex:${selectedCodexProvider.value}`] || ''\n }\n if (selectedCodexProvider.value === 'mimo') {\n request.baseUrl = selectedMimoCodexPlan.value.trim()\n }\n }\n if (platform === 'opencode') {\n request.provider = selectedOpenCodeProvider.value\n if (selectedOpenCodeProvider.value !== 'ccx') {\n const inputKey = openCodeOpenAIKey.value.trim()\n request.apiKey = inputKey || savedProviderKeys.value[`codex:${selectedOpenCodeProvider.value}`] || ''\n }\n if (selectedOpenCodeProvider.value === 'mimo') {\n request.baseUrl = selectedMimoOpenCodePlan.value.trim()\n }\n }\n await ApplyAgentConfig(request)\n await loadAgentStatuses()\n } finally {\n configLoading.value = false\n }\n}\n\nconst showApplyPreview = async (platform: AgentPlatform) => {\n const request: ApplyAgentConfigRequest = { platform }\n if (platform === 'claude') {\n request.provider = selectedClaudeProvider.value\n if (selectedClaudeProvider.value !== 'ccx') {\n const inputKey = claudeProviderKeys.value[selectedClaudeProvider.value].trim()\n request.apiKey = inputKey || findSavedKey(selectedClaudeProvider.value, selectedMimoPlan.value)\n }\n if (selectedClaudeProvider.value === 'mimo') {\n request.baseUrl = claudeMimoBaseUrl.value.trim()\n }\n if (selectedClaudeProvider.value === 'dashscope') {\n request.baseUrl = selectedDashScopePlan.value.trim()\n }\n }\n if (platform === 'codex') {\n request.provider = selectedCodexProvider.value\n if (selectedCodexProvider.value === 'ccx' || isCodexThirdPartyWithMode(selectedCodexProvider.value)) {\n request.mode = codexMode.value\n }\n if (selectedCodexProvider.value === 'openai') {\n // OpenAI 直连只用用户当前输入的 key,不 fallback 到 saved key\n request.apiKey = codexOpenAIKey.value.trim()\n } else if (selectedCodexProvider.value !== 'ccx') {\n const inputKey = codexOpenAIKey.value.trim()\n request.apiKey = inputKey || savedProviderKeys.value[`codex:${selectedCodexProvider.value}`] || ''\n }\n if (selectedCodexProvider.value === 'mimo') {\n request.baseUrl = selectedMimoCodexPlan.value.trim()\n }\n }\n if (platform === 'opencode') {\n request.provider = selectedOpenCodeProvider.value\n if (selectedOpenCodeProvider.value !== 'ccx') {\n const inputKey = openCodeOpenAIKey.value.trim()\n request.apiKey = inputKey || savedProviderKeys.value[`codex:${selectedOpenCodeProvider.value}`] || ''\n }\n if (selectedOpenCodeProvider.value === 'mimo') {\n request.baseUrl = selectedMimoOpenCodePlan.value.trim()\n }\n }\n // 检测模式切换,显示会话迁移警告\n diffWarning.value = undefined\n if (platform === 'codex' && (selectedCodexProvider.value === 'ccx' || isCodexThirdPartyWithMode(selectedCodexProvider.value))) {\n const currentMode = agentStatuses.value.codex?.mode === 'plugin' ? 'plugin' : 'quick'\n if (currentMode !== codexMode.value) {\n diffWarning.value = getT()('agent.sessionMigrationWarning')\n }\n }\n diffPendingPlatform.value = platform\n diffMode.value = 'apply'\n diffDialogOpen.value = true\n diffLoading.value = true\n diffResult.value = null\n try {\n diffResult.value = await PreviewAgentConfigDiff(request) as ConfigDiffResult\n } catch {\n diffResult.value = null\n } finally {\n diffLoading.value = false\n }\n}\n\nconst confirmApply = async () => {\n diffDialogOpen.value = false\n await applyAgent(diffPendingPlatform.value)\n}\n\nconst showRestorePreview = async (platform: AgentPlatform) => {\n diffPendingPlatform.value = platform\n diffMode.value = 'restore'\n diffDialogOpen.value = true\n diffLoading.value = true\n diffResult.value = null\n try {\n diffResult.value = await PreviewRestoreConfigDiff(platform) as ConfigDiffResult\n } catch {\n diffResult.value = null\n } finally {\n diffLoading.value = false\n }\n}\n\nconst confirmRestore = async () => {\n diffDialogOpen.value = false\n await restoreAgent(diffPendingPlatform.value)\n}\n\nconst closeDiffDialog = () => {\n diffDialogOpen.value = false\n}\n\nconst codexSessionTargetProvider = computed(() => {\n if (selectedCodexProvider.value === 'ccx') {\n return codexMode.value === 'plugin' ? 'ccx' : 'openai'\n }\n if (isCodexThirdPartyWithMode(selectedCodexProvider.value)) {\n return codexMode.value === 'plugin' ? selectedCodexProvider.value : 'openai'\n }\n return 'openai'\n})\n\nconst showMigrateDialog = () => {\n migrateResult.value = null\n migrateError.value = ''\n migrateDialogOpen.value = true\n}\n\nconst confirmMigrate = async () => {\n migrateLoading.value = true\n migrateError.value = ''\n migrateResult.value = null\n try {\n migrateResult.value = await MigrateCodexSessions({\n provider: selectedCodexProvider.value,\n mode: codexMode.value,\n }) as MigrateCodexSessionsResult\n } catch (error) {\n migrateError.value = error instanceof Error ? error.message : String(error)\n } finally {\n migrateLoading.value = false\n }\n}\n\nconst closeMigrateDialog = () => {\n if (migrateLoading.value) return\n migrateDialogOpen.value = false\n}\n\nconst restoreAgent = async (platform: AgentPlatform) => {\n configLoading.value = true\n try {\n await RestoreAgentConfig(platform)\n } finally {\n configLoading.value = false\n }\n}\n\nexport function useAgentConfig() {\n return {\n agentStatuses,\n configLoading,\n selectedClaudeProvider,\n claudeProviderKeys,\n savedProviderKeys,\n codexOpenAIKey,\n codexOpenAIUseOwnKey,\n codexMode,\n openCodeOpenAIKey,\n claudeMimoBaseUrl,\n selectedMimoPlan,\n selectedMimoCodexPlan,\n selectedMimoOpenCodePlan,\n selectedDashScopePlan,\n agentLabels,\n claudeProviderLabels,\n codexProviderLabels: getCodexProviderLabels(),\n agentPlatforms,\n isClaudeProvider,\n claudeProviderLabel,\n claudeTargetBaseUrl,\n agentStatusText,\n agentStatusClass,\n loadAgentStatuses,\n canApplyAgent,\n applyAgent,\n restoreAgent,\n selectedCodexProvider,\n selectedOpenCodeProvider,\n codexProviderLabel,\n codexTargetBaseUrl,\n openCodeProviderLabel,\n openCodeTargetBaseUrl,\n // Diff preview\n diffDialogOpen,\n diffResult,\n diffMode,\n diffLoading,\n diffPendingPlatform,\n diffWarning,\n showApplyPreview,\n showRestorePreview,\n confirmApply,\n confirmRestore,\n closeDiffDialog,\n // Codex session migration\n migrateDialogOpen,\n migrateLoading,\n migrateResult,\n migrateError,\n codexSessionTargetProvider,\n showMigrateDialog,\n confirmMigrate,\n closeMigrateDialog,\n }\n}\n"}}, "desktop/frontend/src/locales/zh-CN.json": {"784041ebc2a680321b17c44db2f89e95b9470dbe": {"t": 1785809862.8407009, "n": 73558, "h": "3448c04858cf7ffd", "c": "{\n \"common.gatewayLabel\": \"CCX CORE\",\n \"common.online\": \"GATEWAY ONLINE\",\n \"common.connecting\": \"CONNECTING...\",\n \"common.offline\": \"GATEWAY OFFLINE\",\n \"common.autoGenerated\": \"自动生成\",\n \"common.refreshWebUI\": \"刷新 Web UI\",\n \"common.version\": \"当前版本\",\n \"common.gatewayPort\": \"网关端口\",\n \"common.daemonPid\": \"守护 PID\",\n \"common.autoStart\": \"开机自启\",\n \"common.autoStartOn\": \"已开启\",\n \"common.autoStartOff\": \"已关闭\",\n \"common.serviceHealthy\": \"运行正常\",\n \"common.serviceStarting\": \"网关启动中\",\n \"common.serviceDisconnected\": \"服务已断开\",\n \"common.settings\": \"设置\",\n \"common.save\": \"保存\",\n \"common.cancel\": \"取消\",\n \"common.clearSearch\": \"清除搜索\",\n \"common.retry\": \"重试\",\n \"adminApi.error.serviceNotRunning\": \"服务未运行,无法发送请求\",\n \"adminApi.error.networkUnavailable\": \"服务未运行或网络不可达,请检查后端是否已启动\",\n \"nav.status\": \"网关监控\",\n \"nav.statusDesc\": \"实时状态及核心日志\",\n \"nav.agent\": \"Agent 配置\",\n \"nav.agentDesc\": \"本地开发代理控制\",\n \"nav.channels\": \"渠道中心\",\n \"nav.channelsDesc\": \"协议级渠道编排\",\n \"nav.subscriptions\": \"订阅中心\",\n \"nav.subscriptionsDesc\": \"账号授权与订阅接入\",\n \"nav.cockpit\": \"驾驶舱\",\n \"nav.cockpitDesc\": \"会话与子代理看板\",\n \"nav.env\": \"环境参数\",\n \"nav.envDesc\": \"网关配置文件编辑\",\n \"nav.dashboard\": \"管理面板\",\n \"nav.dashboardDesc\": \"原生管理面板\",\n \"sidebar.managementSection\": \"管理\",\n \"tab.statusTitle\": \"网关状态监控\",\n \"tab.agentTitle\": \"Agent 代理配置\",\n \"tab.channelsTitle\": \"渠道管理控制台\",\n \"tab.subscriptionsTitle\": \"订阅中心\",\n \"tab.cockpitTitle\": \"驾驶舱\",\n \"tab.envTitle\": \"环境参数管理\",\n \"tab.dashboardTitle\": \"管理面板\",\n \"sidebar.language\": \"语言\",\n \"sidebar.languageEnglish\": \"English\",\n \"sidebar.languageChinese\": \"中文\",\n \"setup.loading\": \"正在初始化 CCX 控制台\",\n \"setup.title\": \"CCX Desktop 初始配置\",\n \"setup.description\": \"PROXY_ACCESS_KEY 是 AI Agent 通过 CCX 代理访问上游 API 的身份凭证,所有调用方必须持有此密钥。\",\n \"setup.regenerate\": \"重新生成\",\n \"setup.regenerateTitle\": \"重新生成随机密钥\",\n \"setup.hide\": \"隐藏\",\n \"setup.show\": \"显示\",\n \"setup.copied\": \"已复制\",\n \"setup.copyKey\": \"复制密钥\",\n \"setup.configPath\": \"配置文件路径\",\n \"setup.copyPath\": \"复制路径\",\n \"setup.hint\": \"保存后 CCX 将自动启动。后续可在主界面【环境参数】页继续调整其他配置。\",\n \"setup.saving\": \"正在保存并启动...\",\n \"setup.submit\": \"完成配置并启动\",\n \"webui.notRunning\": \"CCX 服务尚未启动,无法显示 Web UI。\",\n \"webui.openInBrowser\": \"浏览器打开\",\n \"metrics.gatewayPort\": \"网关端口\",\n \"metrics.uptime\": \"运行时长\",\n \"metrics.channels\": \"调度信道\",\n \"metrics.version\": \"网关版本\",\n \"actions.start\": \"启动服务\",\n \"actions.stop\": \"停止服务\",\n \"actions.restart\": \"重启服务\",\n \"actions.openWebUI\": \"打开管理面板\",\n \"actions.openBrowser\": \"浏览器直达\",\n \"actions.refreshStatus\": \"刷新当前状态\",\n \"details.title\": \"服务详情\",\n \"details.binary\": \"二进制\",\n \"details.binaryMissing\": \"未发现\",\n \"details.dataDir\": \"数据目录\",\n \"details.dataDirMissing\": \"未设置\",\n \"details.healthStatus\": \"健康状态\",\n \"details.revealDir\": \"打开所在目录\",\n \"details.openDir\": \"打开目录\",\n \"channel.headerEyebrow\": \"Channel Preset Center\",\n \"channel.title\": \"渠道中心\",\n \"channel.description\": \"统一把 DeepSeek、MiMo、Kimi、GLM、SenseNova、MiniMax、DashScope、讯飞星辰等 Key 可同时用于 Agent 直连和 CCX 统一渠道池,复杂开关由预设自动处理。\",\n \"channel.hasKey\": \"已有 Key\",\n \"channel.promo\": \"通过推广链接注册领取专属福利\",\n \"channel.console\": \"访问官方控制台\",\n \"channel.target\": \"添加目标\",\n \"channel.planLabel\": \"套餐 / Base URL\",\n \"channel.apiKeyLabel\": \"API Key\",\n \"channel.baseUrlPrefix\": \"Base URL\",\n \"channel.keySavedPlaceholder\": \"已保存,留空则复用该 Provider Key\",\n \"channel.keyInputPlaceholder\": \"输入 API Key,仅保存在本机 Desktop 配置中\",\n \"channel.missingKey\": \"请填写 API Key,或先在 Agent 配置中保存该 Provider 的 key。\",\n \"channel.reuseKey\": \"将复用本机已保存的 {provider} Key:{key}\",\n \"channel.name\": \"渠道名称\",\n \"channel.nameHint\": \"同名渠道会被直接覆盖更新;如需新建独立渠道,请改用不同名称。\",\n \"channel.presetWrites\": \"预设将自动写入\",\n \"channel.capabilityHint\": \"能力开关:reasoning / vision / model list / 兼容字段会按 Provider 自动配置。\",\n \"channel.addToCCX\": \"添加到 CCX\",\n \"channel.badgeDirectAgent\": \"Agent 直连\",\n \"channel.badgeNativeMessages\": \"Messages 原生\",\n \"copilotOAuth.description\": \"使用 GitHub OAuth 获取 Copilot 授权,授权成功后会把 GitHub OAuth token 添加为本渠道密钥。该 token 仅用于与 GitHub 交互,CCX 会自动换成短期 Copilot token。\",\n \"copilotOAuth.button\": \"使用 GitHub Copilot 登录\",\n \"copilotOAuth.userCode\": \"授权码\",\n \"copilotOAuth.openAuthorize\": \"打开 GitHub 授权\",\n \"copilotOAuth.waiting\": \"等待 GitHub 授权完成...\",\n \"copilotOAuth.success\": \"授权成功,GitHub OAuth token 已添加到本渠道。\",\n \"copilotOAuth.cancel\": \"取消授权\",\n \"copilotOAuth.expired\": \"授权码已过期,请重新发起授权。\",\n \"subscription.headerEyebrow\": \"Subscription\",\n \"subscription.title\": \"订阅中心\",\n \"subscription.description\": \"集中管理需要账号授权的订阅服务,并将授权结果添加为 CCX 渠道。\",\n \"subscription.authorized\": \"已授权\",\n \"subscription.copilotDescription\": \"使用 GitHub OAuth 获取 Copilot 授权,同一份授权可添加到 Claude、Chat、Codex、Gemini 任一协议渠道。该 token 仅用于与 GitHub 交互,CCX 会自动换成短期 Copilot token。\",\n \"subscription.checkingChannel\": \"正在检查现有 Copilot 渠道...\",\n \"subscription.verifying\": \"正在校验 GitHub 账号...\",\n \"subscription.addingCopilotAccount\": \"正在加入账号到所选渠道...\",\n \"subscription.authorizeCopilot\": \"授权 GitHub Copilot\",\n \"subscription.authorizeAndAddAccount\": \"授权并加入新账号\",\n \"subscription.addExistingAccount\": \"加入已授权账号\",\n \"subscription.copilotAuthorizationSavedOnly\": \"已获授权;当前未检测到 {target} 渠道,点击下方按钮即可添加。\",\n \"subscription.retryAddAccount\": \"重试加入账号\",\n \"subscription.accountsTitle\": \"{target} 已授权账号({count})\",\n \"subscription.accountCountShort\": \"{count} 个账号\",\n \"subscription.accountsEmpty\": \"当前 {target} 渠道还没有已授权账号。\",\n \"subscription.accountsSummary\": \"{target} 渠道已累积 {count} 个授权账号\",\n \"subscription.accountUnnamed\": \"未命名账号\",\n \"subscription.accountRemove\": \"移除该账号\",\n \"subscription.accountRemoveConfirm\": \"确认移除\",\n \"subscription.verifyFailed\": \"未能获取该 GitHub 账号信息,请重试\",\n \"subscription.channelResolveFailed\": \"渠道创建后未能定位,请重试\",\n \"subscription.targetLabel\": \"添加到协议渠道\",\n \"subscription.channelExists\": \"已存在\",\n \"subscription.targetClaude\": \"Claude\",\n \"subscription.targetClaudeDesc\": \"添加到 Messages 渠道,供 Claude Code 协议入口使用\",\n \"subscription.targetChat\": \"Chat\",\n \"subscription.targetChatDesc\": \"添加到 OpenAI Chat Completions 渠道\",\n \"subscription.targetCodex\": \"Codex\",\n \"subscription.targetCodexDesc\": \"添加到 Responses 渠道,供 Codex 协议入口使用\",\n \"subscription.targetGemini\": \"Gemini\",\n \"subscription.targetGeminiDesc\": \"添加到 Gemini 协议渠道\",\n \"subscription.field.uid\": \"订阅 ID\",\n \"subscription.field.name\": \"显示名称\",\n \"subscription.field.notes\": \"备注\",\n \"subscription.newApi.connect\": \"接入 new-api\",\n \"subscription.newApi.step1Title\": \"第一步:校验 Token\",\n \"subscription.newApi.step2Title\": \"第二步:确认接入信息\",\n \"subscription.newApi.baseUrl\": \"new-api 地址\",\n \"subscription.newApi.accessToken\": \"Access Token\",\n \"subscription.newApi.userId\": \"用户 ID(可选)\",\n \"subscription.newApi.authTokenMode\": \"Token 模式\",\n \"subscription.newApi.verify\": \"校验\",\n \"subscription.newApi.reVerify\": \"重新校验\",\n \"subscription.newApi.accountPreview\": \"账号预览\",\n \"subscription.newApi.username\": \"用户名\",\n \"subscription.newApi.quota\": \"额度\",\n \"subscription.newApi.usedQuota\": \"已用额度\",\n \"subscription.newApi.availableModels\": \"可用模型数\",\n \"subscription.newApi.groups\": \"分组倍率\",\n \"subscription.newApi.channelKind\": \"渠道类型\",\n \"subscription.newApi.channelName\": \"渠道名称(可选)\",\n \"subscription.newApi.provisionGroup\": \"接入分组(可选)\",\n \"subscription.newApi.provision\": \"接入\",\n \"subscription.newApi.provisionSuccess\": \"已自动创建密钥并接入渠道,密钥已写入渠道配置,不会再次显示。\",\n \"subscription.newApi.discoveryStarted\": \"模型发现已启动。\",\n \"channel.preset.deepseek.label\": \"DeepSeek\",\n \"channel.preset.deepseek.description\": \"V4-Pro / V4-Flash 双旗舰推理模型,突破性成本。完全开源(MIT),以极具竞争力的价格提供前沿级性能。\",\n \"channel.preset.mimo.label\": \"MiMo\",\n \"channel.preset.mimo.description\": \"小米自研 AI 平台,面向 Agent 时代打造。旗舰 MiMo-V2.5-Pro 具备万亿参数与百万上下文窗口,赋能小米跨设备、车、家的智能生态。\",\n \"channel.preset.compshare.label\": \"优云智算套餐\",\n \"channel.preset.compshare.description\": \"优云智算是 UCloud 旗下 AI 云平台,提供高性价比国内 AI 模型 Agent Plan 套餐,支持包月订阅或按量付费(49 元/月起),同时提供官方海外模型稳定接入,支持 Claude Code、Codex 与 API 集成,具备企业级高并发、7×24 技术支持和自助开票能力;通过推广链接注册可领取 5 元平台试用金。\",\n \"channel.preset.runapi.label\": \"RunAPI\",\n \"channel.preset.runapi.description\": \"RunAPI 是高效稳定的API OpenRouter平替平台,一个 API Key 即可访问 OpenAI、Claude、Gemini、DeepSeek、Grok 等 150+ 主流模型,低至 1 折,极其稳定,可以无缝兼容 Claude Code、OpenClaw 等工具。RunAPI 为 CCX用户提供专属福利:注册联系管理员即可领取¥7的免费额度\",\n \"channel.preset.kimi.label\": \"Kimi / Moonshot\",\n \"channel.preset.kimi.description\": \"Moonshot AI 智能助手平台。旗舰 Kimi K2.6 支持原生多模态,以超长上下文和 Agent Swarm 能力著称。\",\n \"channel.preset.glm.label\": \"GLM / BigModel\",\n \"channel.preset.glm.description\": \"清华系智谱 AI MaaS 平台。旗舰 GLM-5.1 在编程领域达到前沿模型水平,港股上市,全栈华为昇腾芯片训练。\",\n \"channel.preset.sensenova.label\": \"SenseNova\",\n \"channel.preset.sensenova.description\": \"商汤日日新 LLM API 平台,兼容 OpenAI 与 Anthropic 协议,预设使用 GLM-5.2、DeepSeek V4 Flash 与 SenseNova 6.7 Flash-Lite 多模态回退。\",\n \"channel.preset.minimax.label\": \"MiniMax\",\n \"channel.preset.minimax.description\": \"全球多模态 AI 领导者(港股上市)。旗舰 M3 具备百万上下文、原生视觉与 Agent 能力;海螺视频/音频模型位居全球前列;超 70% 收入来自海外市场。\",\n \"channel.preset.dashscope.label\": \"阿里云 DashScope\",\n \"channel.preset.dashscope.description\": \"阿里云一站式 LLM 平台,集成通义千问及第三方模型,全模态覆盖,提供按量、编程套餐与 Token Plan 选项。\",\n \"channel.preset.opencode-zen.label\": \"OpenCode Zen / Go\",\n \"channel.preset.opencode-zen.description\": \"OpenCode 官方精选模型网关,Go 订阅套餐与 Zen 按量套餐共用同一套编程模型预设。\",\n \"channel.preset.opencode-go.label\": \"OpenCode Go\",\n \"channel.preset.opencode-go.description\": \"低成本开源编程模型订阅服务($5/月起),支持 Messages、Chat、Responses 三种协议。\",\n \"channel.preset.deepseek.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.deepseek.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.deepseek.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.deepseek.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.mimo.plan.anthropic.label\": \"按量 (Anthropic)\",\n \"channel.preset.mimo.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.mimo.plan.openai-chat.label\": \"按量 (OpenAI)\",\n \"channel.preset.mimo.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.mimo.plan.token-cn.label\": \"Token Plan - 中国 (OpenAI)\",\n \"channel.preset.mimo.plan.token-cn.description\": \"中国区 Token Plan Chat / Responses 通用入口\",\n \"channel.preset.mimo.plan.token-sgp.label\": \"Token Plan - 新加坡 (OpenAI)\",\n \"channel.preset.mimo.plan.token-sgp.description\": \"新加坡区 Token Plan Chat / Responses 通用入口\",\n \"channel.preset.mimo.plan.token-ams.label\": \"Token Plan - 欧洲 (OpenAI)\",\n \"channel.preset.mimo.plan.token-ams.description\": \"欧洲区 Token Plan Chat / Responses 通用入口\",\n \"channel.preset.mimo.plan.token-cn-anthropic.label\": \"Token Plan - 中国 (Anthropic)\",\n \"channel.preset.mimo.plan.token-cn-anthropic.description\": \"中国区 Token Plan Claude Messages 原生入口\",\n \"channel.preset.mimo.plan.token-sgp-anthropic.label\": \"Token Plan - 新加坡 (Anthropic)\",\n \"channel.preset.mimo.plan.token-sgp-anthropic.description\": \"新加坡区 Token Plan Claude Messages 原生入口\",\n \"channel.preset.mimo.plan.token-ams-anthropic.label\": \"Token Plan - 欧洲 (Anthropic)\",\n \"channel.preset.mimo.plan.token-ams-anthropic.description\": \"欧洲区 Token Plan Claude Messages 原生入口\",\n \"channel.preset.compshare.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.compshare.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.compshare.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.compshare.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.runapi.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.runapi.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.runapi.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.runapi.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.unity2.label\": \"Unity2.ai\",\n \"channel.preset.unity2.description\": \"Unity2.ai 是面向个人开发者、团队、企业的高性能 AI 模型 API 中转平台,长期服务国内头部企业,日均承载超 300 亿 token 调用,支持 5000 RPM 级高并发。一个 API Key 即可适配 Claude Code、Codex、OpenAI 模型、IDE 插件和 Agent 工作流等场景。具备企业级稳定供应能力,在高并发、持续调用和团队集中采购场景下依然保持低延迟、高可用。现在注册 Unity2.ai 可领取 $2 余额,加入官方群再送 $10 余额,合计最高可领 $12 免费额度。\",\n \"channel.preset.unity2.plan.anthropic.label\": \"Anthropic 兼容\",\n \"channel.preset.unity2.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.unity2.plan.openai-chat.label\": \"OpenAI 兼容\",\n \"channel.preset.unity2.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.kimi.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.kimi.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.kimi.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.kimi.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.kimi.plan.coding-anthropic.label\": \"Coding Plan (Anthropic)\",\n \"channel.preset.kimi.plan.coding-anthropic.description\": \"Coding Plan Claude Messages 原生入口\",\n \"channel.preset.kimi.plan.coding-openai-chat.label\": \"Coding Plan (OpenAI)\",\n \"channel.preset.kimi.plan.coding-openai-chat.description\": \"Coding Plan Chat / Responses 通用入口\",\n \"channel.preset.glm.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.glm.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.glm.plan.coding.label\": \"Coding Plan (OpenAI)\",\n \"channel.preset.glm.plan.coding.description\": \"Coding Plan Chat / Responses 通用入口\",\n \"channel.preset.glm.plan.openai-chat.label\": \"通用 (OpenAI)\",\n \"channel.preset.glm.plan.openai-chat.description\": \"通用 Chat / Responses 入口\",\n \"channel.preset.sensenova.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.sensenova.plan.anthropic.description\": \"Claude Messages 兼容入口\",\n \"channel.preset.sensenova.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.sensenova.plan.openai-chat.description\": \"Chat 入口,Responses 由 CCX 转换\",\n \"channel.preset.minimax.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.minimax.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.minimax.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.minimax.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.dashscope.plan.anthropic.label\": \"按量 (Anthropic)\",\n \"channel.preset.dashscope.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.dashscope.plan.openai-chat.label\": \"按量 (OpenAI)\",\n \"channel.preset.dashscope.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.dashscope.plan.coding-anthropic.label\": \"Coding Plan (Anthropic)\",\n \"channel.preset.dashscope.plan.coding-anthropic.description\": \"Coding Plan Claude Messages 原生入口\",\n \"channel.preset.tencent-lkeap.label\": \"腾讯云 TokenHub\",\n \"channel.preset.tencent-lkeap.description\": \"腾讯云大模型服务平台,整合混元及 DeepSeek、Kimi 等主流模型,提供 Token Plan 订阅和按量计费,适配 Claude Code、Codex 等主流编程工具。\",\n \"channel.preset.tencent-lkeap.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.tencent-lkeap.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.tencent-lkeap.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.tencent-lkeap.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.kimi-code.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.kimi-code.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.kimi-code.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.kimi-code.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.volc-ark.label\": \"火山方舟 Coding Plan\",\n \"channel.preset.volc-ark.description\": \"字节跳动火山引擎旗下一站式 AI 平台,提供豆包系列模型精调、推理、评测全链路服务,覆盖多厂商模型,企业级保障完善。\",\n \"channel.preset.volc-ark.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.volc-ark.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.volc-ark.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.volc-ark.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.qianfan.label\": \"百度千帆 Coding Plan\",\n \"channel.preset.qianfan.description\": \"百度智能云企业级大模型平台,内置文心 5.0 原生全模态大模型,支持多智能体协同,提供从开发到部署的全链路工具。\",\n \"channel.preset.qianfan.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.qianfan.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.qianfan.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.qianfan.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.originrouter.label\": \"极易云 OriginRouter\",\n \"channel.preset.originrouter.description\": \"极易云统一模型 API 转发平台,一个密钥无缝切换 GPT、Claude、Gemini 等主流模型,高速稳定,适合多模型混用场景。\",\n \"channel.preset.originrouter.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.originrouter.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.originrouter.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.originrouter.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.dashscope.plan.coding-openai-chat.label\": \"Coding Plan (OpenAI)\",\n \"channel.preset.dashscope.plan.coding-openai-chat.description\": \"Coding Plan Chat / Responses 通用入口\",\n \"channel.preset.dashscope.plan.token-plan-anthropic.label\": \"Token Plan (Anthropic)\",\n \"channel.preset.dashscope.plan.token-plan-anthropic.description\": \"Token Plan Claude Messages 原生入口\",\n \"channel.preset.dashscope.plan.token-plan-openai-chat.label\": \"Token Plan (OpenAI)\",\n \"channel.preset.dashscope.plan.token-plan-openai-chat.description\": \"Token Plan Chat / Responses 通用入口\",\n \"channel.preset.opencode-zen.plan.go-openai-chat.label\": \"Go 订阅套餐\",\n \"channel.preset.opencode-zen.plan.go-openai-chat.description\": \"低成本订阅入口,Messages 经 CCX 转 Chat 上游,Chat / Responses 直接使用该入口\",\n \"channel.preset.opencode-zen.plan.openai-chat.label\": \"Zen 按量套餐\",\n \"channel.preset.opencode-zen.plan.openai-chat.description\": \"按量付费入口,Messages 经 CCX 转 Chat 上游,Chat / Responses 直接使用该入口\",\n \"channel.preset.opencode-go.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.opencode-go.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.opencode-go.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.opencode-go.plan.openai-chat.description\": \"Messages 经 CCX 转 Chat 上游,Chat / Responses 直接使用该入口\",\n \"channel.preset.modelscope.label\": \"ModelScope\",\n \"channel.preset.modelscope.description\": \"阿里达摩院开源模型社区,涵盖文本、图像、语音等领域 10,000+ 模型,提供模型探索、推理与训练全链路支持。\",\n \"channel.preset.modelscope.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.modelscope.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.modelscope.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.modelscope.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.xfyun.label\": \"讯飞星辰\",\n \"channel.preset.xfyun.description\": \"科大讯飞星辰 MaaS Astron Coding Plan,提供 OpenAI、Anthropic 与 Responses 三个独立 Coding Plan 入口。\",\n \"channel.preset.xfyun.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.xfyun.plan.anthropic.description\": \"Claude Messages Coding Plan 入口\",\n \"channel.preset.xfyun.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.xfyun.plan.openai-chat.description\": \"OpenAI Chat Coding Plan 入口\",\n \"channel.preset.xfyun.plan.responses.label\": \"Responses-compatible\",\n \"channel.preset.xfyun.plan.responses.description\": \"OpenAI Responses Coding Plan 入口\",\n \"channel.preset.openrouter.label\": \"OpenRouter\",\n \"channel.preset.openrouter.description\": \"最大规模 AI 模型聚合平台,单个 API Key 即可访问数百款模型。OpenAI 兼容协议,内置安全治理与成本控制能力。\",\n \"channel.preset.openrouter.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.openrouter.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.openrouter.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.openrouter.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.target.messages.label\": \"Claude Messages\",\n \"channel.target.messages.description\": \"Claude 原生 Messages 协议,支持 Claude Code 直连或通过 CCX 代理\",\n \"channel.target.responses.label\": \"Codex Responses\",\n \"channel.target.responses.description\": \"OpenAI Responses 协议,专供 Codex CLI 及兼容客户端使用\",\n \"channel.target.chat.label\": \"OpenAI Chat\",\n \"channel.target.chat.description\": \"OpenAI Chat Completions 协议,兼容各类 Chat 客户端和第三方工具\",\n \"env.title\": \"环境配置\",\n \"env.pathDetecting\": \"检测中\",\n \"env.refresh\": \"刷新\",\n \"env.openingEditor\": \"打开中…\",\n \"env.openWithEditor\": \"用 {editor} 打开\",\n \"env.openInEditor\": \"用编辑器打开\",\n \"env.selectEditor\": \"选择编辑器…\",\n \"env.save\": \"保存\",\n \"env.saving\": \"保存中\",\n \"env.saved\": \"已保存\",\n \"env.failed\": \"失败\",\n \"env.hide\": \"隐藏\",\n \"env.show\": \"显示\",\n \"env.copied\": \"已复制\",\n \"env.copy\": \"复制\",\n \"env.fieldRequired\": \"{field}不能为空\",\n \"env.fieldDisallow\": \"{field}不能使用示例占位值\",\n \"env.fieldNumber\": \"{field}必须是数字\",\n \"env.fieldInteger\": \"{field}必须是整数\",\n \"env.fieldMin\": \"{field}不能小于 {min}\",\n \"env.fieldMax\": \"{field}不能大于 {max}\",\n \"env.loadFailed\": \"加载配置失败:{error}\",\n \"env.saveSuccess\": \".env 已保存,重启服务后生效\",\n \"agent.statusDetecting\": \"检测中\",\n \"agent.statusConfigured\": \"已配置\",\n \"agent.statusPortMismatch\": \"端口不匹配\",\n \"agent.statusUnconfigured\": \"未配置\",\n \"agent.localGateway\": \"当前 CCX 网关\",\n \"agent.custom\": \"自定义\",\n \"agent.currentProvider\": \"当前 Provider\",\n \"agent.currentUrl\": \"当前 URL\",\n \"agent.targetUrl\": \"目标 URL\",\n \"agent.notSet\": \"未设置\",\n \"agent.configPath\": \"配置文件\",\n \"agent.authPath\": \"认证文件\",\n \"agent.openFileInEditor\": \"用编辑器打开\",\n \"agent.applyConfig\": \"应用配置\",\n \"agent.restoreConfig\": \"恢复原始配置\",\n \"agent.migrateSessions\": \"迁移会话\",\n \"agent.migrateConfirmTitle\": \"迁移 Codex 会话\",\n \"agent.migrateConfirmDesc\": \"将 Codex 本地会话元数据改写为当前 provider,覆盖历史会话、归档会话和 Codex 状态数据库,但不会删除任何会话。\",\n \"agent.migrateTarget\": \"目标 model_provider\",\n \"agent.migrateRunning\": \"正在迁移会话...\",\n \"agent.migrateConfirm\": \"确认迁移\",\n \"agent.migrateClose\": \"关闭\",\n \"agent.migrateResultSuccess\": \"迁移完成:{migrated}/{total} 个会话文件已更新\",\n \"agent.migrateResultSkippedLabel\": \"跳过文件\",\n \"agent.migrateResultFailedLabel\": \"失败文件\",\n \"agent.migrateSQLiteUpdated\": \"SQLite 数据库已更新:{count} 行\",\n \"agent.migrateSQLiteSkipped\": \"SQLite 数据库已跳过\",\n \"agent.openConsole\": \"访问官方控制台\",\n \"agent.codexPlaceholderSaved\": \"已保存,留空则使用已保存的 key\",\n \"agent.codexPlaceholderRequired\": \"必填:输入 API Key\",\n \"agent.codexPlaceholderWriteOnly\": \"仅写入 Codex 配置\",\n \"agent.diffPreviewApply\": \"应用配置预览\",\n \"agent.diffPreviewRestore\": \"恢复配置预览\",\n \"agent.diffConfirmApply\": \"确认应用\",\n \"agent.diffConfirmRestore\": \"确认恢复\",\n \"agent.diffActionCreate\": \"创建\",\n \"agent.diffActionDelete\": \"删除\",\n \"agent.diffActionModify\": \"修改\",\n \"agent.diffComputing\": \"计算变更中...\",\n \"agent.diffNoChanges\": \"无变更\",\n \"agent.diffExpandContext\": \"展开 {count} 行未变更内容\",\n \"agent.diffCollapseContext\": \"收起 {count} 行\",\n \"agent.diffCancel\": \"取消\",\n \"agent.codexMode\": \"模式\",\n \"agent.codexQuickMode\": \"快捷模式\",\n \"agent.codexPluginMode\": \"插件模式\",\n \"agent.codexQuickModeHint\": \"保留 OpenAI 对话历史。使用 OpenAI provider + 目标 API 端点。\",\n \"agent.codexPluginModeHint\": \"启用 Codex 插件功能。切换后之前的 OpenAI 对话可能不可见,切回快捷模式即可访问。\",\n \"agent.sessionMigrationWarning\": \"切换 Codex 模式后,已有会话可能不可见。会话不会被删除 — 切回之前的模式即可访问。\",\n \"agent.provider.localGateway\": \"CCX 本地网关\",\n \"agent.provider.deepseekDirect\": \"DeepSeek 直连\",\n \"agent.provider.mimoDirect\": \"MiMo 直连\",\n \"agent.provider.compshareDirect\": \"Compshare 直连\",\n \"agent.provider.runapiDirect\": \"RunAPI 直连\",\n \"agent.provider.unity2Direct\": \"Unity2.ai 直连\",\n \"agent.provider.kimiDirect\": \"Kimi 直连\",\n \"agent.provider.glmDirect\": \"GLM 直连\",\n \"agent.provider.sensenovaDirect\": \"SenseNova 直连\",\n \"agent.provider.minimaxDirect\": \"MiniMax 直连\",\n \"agent.provider.dashscopeDirect\": \"DashScope 直连\",\n \"agent.provider.opencodeZenDirect\": \"OpenCode Zen 直连\",\n \"agent.provider.opencodeGoDirect\": \"OpenCode Go 直连\",\n \"agent.provider.openaiDirect\": \"OpenAI 直连\",\n \"agent.provider.openrouterDirect\": \"OpenRouter 直连\",\n \"agent.provider.modelscopeDirect\": \"ModelScope 直连\",\n \"agent.provider.xfyunDirect\": \"讯飞星辰直连\",\n \"agent.provider.tencentLkeapDirect\": \"腾讯云 TokenHub 直连\",\n \"agent.provider.volcArkDirect\": \"火山方舟直连\",\n \"agent.provider.qianfanDirect\": \"百度千帆直连\",\n \"agent.hasOwnApiKey\": \"我有自己的 API Key\",\n \"agent.promo\": \"通过推广链接注册领取专属福利\",\n \"agent.planPayAsYouGo\": \"按量\",\n \"agent.planChina\": \"订阅套餐 - 中国\",\n \"agent.planSingapore\": \"订阅套餐 - 新加坡\",\n \"agent.planEurope\": \"订阅套餐 - 欧洲\",\n \"agent.planSubscription\": \"订阅套餐\",\n \"agent.billingModeMiMo\": \"MiMo 计费模式\",\n \"agent.billingModeDashScope\": \"DashScope 计费模式\",\n \"agent.placeholderSaved\": \"已保存,留空则使用已保存的 key\",\n \"agent.placeholderMimo\": \"必填:MiMo API Key(tp-xxx 或账号 key)\",\n \"agent.placeholderDashScope\": \"必填:DashScope API Key(sk-xxx 或 sk-sp-xxx)\",\n \"agent.placeholderRequired\": \"必填:输入 API Key\",\n \"agent.codexDiagnosticTitle\": \"Codex 排障诊断\",\n \"agent.codexTroubleshoot\": \"排障检测\",\n \"agent.codexTroubleshooting\": \"检测中...\",\n \"agent.codexDiagnosticHealthy\": \"当前 Codex 配置与 CCX 看起来是一致的。\",\n \"agent.codexDiagnosticMismatch\": \"config.toml 与 auth.json 可能不一致。\",\n \"agent.codexDiagnosticCheck\": \"检查项\",\n \"agent.codexDiagnosticAuthMode\": \"认证模式\",\n \"agent.codexDiagnosticConfigPath\": \"config.toml\",\n \"agent.codexDiagnosticAuthPath\": \"auth.json\",\n \"agent.codexDiagnosticSuggestedApply\": \"建议动作:重新应用 Codex -> CCX 配置。\",\n \"agent.codexDiagnosticSuggestedRestore\": \"如果问题仍在,先恢复原始配置,再重新应用。\",\n \"agent.codexDiagnosticStatusTitle\": \"Codex 排障摘要\",\n \"agent.codexDiagnosticStatusHint\": \"请前往 Agent 页检查 Codex 配置一致性。\",\n \"agent.codexDiagnosticCodeMissingApiKey\": \"config.toml 已指向本地 CCX,但 auth.json 缺少 OPENAI_API_KEY。\",\n \"agent.codexDiagnosticCodeProxyKeyMismatch\": \"Codex 当前保存的代理密钥与 CCX 当前代理密钥不一致。\",\n \"agent.codexDiagnosticCodeAuthModeMismatch\": \"config.toml 与 auth.json 要求的认证模式不一致。\",\n \"agent.codexDiagnosticCodePluginMissingBearer\": \"插件模式缺少 experimental_bearer_token。\",\n \"agent.codexDiagnosticCodeLegacyIncomplete\": \"旧式 ccx provider 配置不完整。\",\n \"agent.codexDiagnosticCodeConfigPolluted\": \"config.toml 可能被其他工具污染,已不符合 CCX 预期。\",\n \"agent.codexDiagnosticCodeAuthUnreadable\": \"auth.json 无法解析,可能已经损坏。\",\n \"agent.codexDiagnosticCodeModeMismatch\": \"当前 Codex 配置是{currentMode}模式,但你选择了{targetMode}模式。请重新应用配置。\",\n \"agent.responsesDiagnosticTitle\": \"Responses 渠道诊断\",\n \"agent.responsesDiagnosticHealthy\": \"Responses 渠道当前看起来可用。\",\n \"agent.responsesDiagnosticNoChannel\": \"CCX 当前没有配置任何 Responses 渠道。\",\n \"agent.responsesDiagnosticAllDisabled\": \"已存在 Responses 渠道,但当前没有任何活跃渠道。\",\n \"agent.responsesDiagnosticNoApiKeys\": \"活跃的 Responses 渠道缺少 API Key。\",\n \"agent.responsesDiagnosticProtocolMismatch\": \"已存在 Responses 渠道,但它们的协议路由看起来并不兼容 /v1/responses。\",\n \"agent.responsesDiagnosticCircuitOpen\": \"所有活跃的 Responses 渠道当前都处于熔断状态。\",\n \"agent.responsesDiagnosticSuggestedRefresh\": \"建议动作:刷新渠道状态和日志。\",\n \"agent.responsesDiagnosticSuggestedCreate\": \"建议动作:为当前 Codex provider 新增一个 Responses 渠道。\",\n \"agent.responsesDiagnosticSuggestedEnable\": \"建议动作:启用或恢复至少一个 Responses 渠道。\",\n \"agent.responsesDiagnosticSuggestedAddKey\": \"建议动作:给活跃的 Responses 渠道补充 API Key。\",\n \"agent.responsesDiagnosticSuggestedProtocolMismatch\": \"建议动作:检查 serviceType 和 baseUrl,确保该渠道最终落到 /responses 端点。\",\n \"agent.responsesDiagnosticSuggestedResume\": \"建议动作:查看日志并在恢复后重新启用相关渠道。\",\n \"agent.responsesLogsDiagnosticTitle\": \"最近失败请求\",\n \"agent.responsesLogsDiagnosticHealthy\": \"未发现最近的 Responses 失败请求。\",\n \"agent.responsesLogsDiagnosticAuth\": \"最近的 Responses 请求出现了认证错误(401/403)。\",\n \"agent.responsesLogsDiagnosticRateLimit\": \"最近的 Responses 请求触发了限流(429)。\",\n \"agent.responsesLogsDiagnosticUpstream5xx\": \"最近的 Responses 请求出现了上游 5xx 错误。\",\n \"agent.responsesLogsDiagnosticTimeout\": \"最近的 Responses 请求看起来发生了超时或卡住。\",\n \"agent.responsesLogsDiagnosticGeneric\": \"最近的 Responses 请求失败了,请查看渠道日志获取细节。\",\n \"agent.responsesLogsDiagnosticSuggestedRefresh\": \"建议动作:刷新最近的渠道日志。\",\n \"agent.responsesLogsDiagnosticSuggestedAuth\": \"建议动作:检查上游 API Key、账户额度和认证设置。\",\n \"agent.responsesLogsDiagnosticSuggestedRateLimit\": \"建议动作:降低请求频率,或等待上游限流窗口恢复。\",\n \"agent.responsesLogsDiagnosticSuggestedUpstream5xx\": \"建议动作:切换渠道、检查上游状态,或稍后再试。\",\n \"agent.responsesLogsDiagnosticSuggestedTimeout\": \"建议动作:检查上游延迟、超时配置和最近日志。\",\n \"agent.responsesLogsDiagnosticSuggestedGeneric\": \"建议动作:打开 Responses 渠道日志并检查最近失败项。\",\n \"agent.codexDiagnosticIntro\": \"按需排障检测:配置一致性 → Responses 渠道可用性 → 最近失败请求。\",\n \"agent.codexDiagnosticLayerConfig\": \"第 1 层 · Codex 配置\",\n \"agent.codexDiagnosticLayerChannels\": \"第 2 层 · Responses 渠道\",\n \"agent.codexDiagnosticLayerLogs\": \"第 3 层 · 最近失败请求\",\n \"env.groupAccess\": \"访问控制\",\n \"env.groupAccessDesc\": \"代理入口与管理入口的访问密钥。\",\n \"env.fieldProxyAccessKey\": \"代理访问密钥\",\n \"env.placeholderProxyAccessKey\": \"请输入强随机密钥\",\n \"env.fieldAdminAccessKey\": \"管理 API 独立密钥\",\n \"env.placeholderAdminAccessKey\": \"留空则回退到 PROXY_ACCESS_KEY\",\n \"env.descAdminAccessKey\": \"用于管理界面和 /api/* 端点。\",\n \"env.groupServer\": \"服务器配置\",\n \"env.groupServerDesc\": \"Desktop 会在启动时注入部分运行参数;这里仍完整覆盖 .env.example。\",\n \"env.fieldPort\": \"服务端口\",\n \"env.descPort\": \"启动时优先使用此端口,被占用时自动递增分配。\",\n \"env.fieldBindHost\": \"监听主机\",\n \"env.descBindHost\": \"留空等价于监听所有网卡(:PORT / 0.0.0.0);填写 127.0.0.1 则只允许本机访问。\",\n \"env.fieldEnableHttps\": \"启用本地 HTTPS\",\n \"env.descEnableHttps\": \"用于 Claude Desktop 等只接受 HTTPS 网关的客户端;开启后 WebURL 和健康检查都会切换为 https。\",\n \"env.fieldTlsAutoCert\": \"自动生成本地证书\",\n \"env.descTlsAutoCert\": \"未提供证书文件时生成临时 localhost 自签名证书;如客户端要求受信任证书,请改用证书文件。\",\n \"env.fieldTlsCertFile\": \"TLS 证书文件\",\n \"env.placeholderTlsCertFile\": \"例如 /Users/me/.config/ccx/localhost.pem\",\n \"env.descTlsCertFile\": \"PEM 格式证书路径;与 TLS_KEY_FILE 同时设置。\",\n \"env.fieldTlsKeyFile\": \"TLS 私钥文件\",\n \"env.placeholderTlsKeyFile\": \"例如 /Users/me/.config/ccx/localhost-key.pem\",\n \"env.descTlsKeyFile\": \"PEM 格式私钥路径;与 TLS_CERT_FILE 同时设置。\",\n \"env.fieldEnv\": \"运行环境\",\n \"env.descEnv\": \"production 为推荐值。\",\n \"env.groupWebUI\": \"Web UI 配置\",\n \"env.groupWebUIDesc\": \"控制管理界面是否启用以及默认语言。\",\n \"env.fieldEnableWebUI\": \"启用 Web UI\",\n \"env.descEnableWebUI\": \"Desktop 模式通常会强制启用。\",\n \"env.fieldAppUILanguage\": \"默认语言\",\n \"env.groupLogs\": \"日志配置\",\n \"env.groupLogsDesc\": \"控制请求/响应日志、SSE 调试和模型字段改写。\",\n \"env.fieldLogLevel\": \"日志级别\",\n \"env.fieldEnableRequestLogs\": \"启用请求日志\",\n \"env.fieldEnableResponseLogs\": \"启用响应日志\",\n \"env.descEnableResponseLogs\": \"响应日志可能增加敏感内容暴露风险。\",\n \"env.fieldQuietPollingLogs\": \"静默轮询日志\",\n \"env.fieldRawLogOutput\": \"原始日志输出\",\n \"env.fieldSseDebugLevel\": \"SSE 调试级别\",\n \"env.fieldRewriteResponseModel\": \"改写响应 model\",\n \"env.groupPerformance\": \"性能配置\",\n \"env.groupPerformanceDesc\": \"请求链路超时和请求体大小限制。\",\n \"env.fieldServerReadTimeout\": \"服务端读取超时(毫秒)\",\n \"env.fieldMaxRequestBodySize\": \"请求体最大大小(MB)\",\n \"env.groupCors\": \"CORS 配置\",\n \"env.groupCorsDesc\": \"跨域访问控制。\",\n \"env.fieldEnableCors\": \"启用 CORS\",\n \"env.fieldCorsOrigin\": \"允许的 Origin\",\n \"env.groupCircuitBreaker\": \"熔断指标配置\",\n \"env.groupCircuitBreakerDesc\": \"控制调度指标窗口与失败率阈值。\",\n \"env.fieldMetricsWindowSize\": \"滑动窗口大小\",\n \"env.fieldMetricsFailureThreshold\": \"失败率阈值\",\n \"env.runtimeCbTitle\": \"调校台\",\n \"env.runtimeCbDesc\": \"精调熔断与流控参数,修改立即生效\",\n \"env.runtimeCbWindowSize\": \"滑动窗口大小\",\n \"env.runtimeCbWindowSizeDesc\": \"计算失败率的最近请求数(3-100)\",\n \"env.runtimeCbFailureThreshold\": \"失败率阈值\",\n \"env.runtimeCbFailureThresholdDesc\": \"超过此值触发熔断(0.01-1.0)\",\n \"env.runtimeCbConsecutiveFailures\": \"连续失败阈值\",\n \"env.runtimeCbConsecutiveFailuresDesc\": \"连续可重试失败次数达此值触发熔断(1-100)\",\n \"env.runtimeCbRequestTimeout\": \"非流式请求超时\",\n \"env.runtimeCbResponseHeaderTimeout\": \"响应头等待超时\",\n \"env.runtimeCbStreamFirstContentTimeout\": \"首字等待超时\",\n \"env.runtimeCbStreamFirstContentTimeoutDesc\": \"HTTP 200 后等待首个有效内容的时间(0=禁用, 1000-300000ms)\",\n \"env.runtimeCbStreamInactivityTimeout\": \"首字后断流超时\",\n \"env.runtimeCbStreamInactivityTimeoutDesc\": \"首字后等待后续活动的时间(0=禁用, 1000-180000ms)\",\n \"env.runtimeCbStreamToolCallIdleTimeout\": \"工具调用空闲超时\",\n \"env.runtimeCbStreamToolCallIdleTimeoutDesc\": \"工具调用 pending 时等待有效输出的时间(1000-180000ms)\",\n \"env.runtimeCbPresetGentle\": \"温和\",\n \"env.runtimeCbPresetBalanced\": \"均衡\",\n \"env.runtimeCbPresetAggressive\": \"激进\",\n \"env.runtimeCbPresetCustom\": \"自定义\",\n \"env.runtimeCbSaved\": \"调校台设置已保存,修改立即生效\",\n \"env.runtimeCbSaveFailed\": \"保存失败:{error}\",\n \"env.runtimeCbLoadFailed\": \"加载失败:{error}\",\n \"env.runtimeCbNoBackend\": \"后端服务未运行,请先启动服务\",\n \"env.runtimeCbServiceStopped\": \"服务已停止,启动后可配置运行时参数\",\n \"env.groupMetricsPersistence\": \"指标持久化配置\",\n \"env.groupMetricsPersistenceDesc\": \"控制 SQLite 指标持久化与数据保留。\",\n \"env.fieldMetricsPersistenceEnabled\": \"启用指标持久化\",\n \"env.fieldMetricsRetentionDays\": \"指标保留天数\",\n \"logs.searchPlaceholder\": \"搜索日志...\",\n \"logs.autoScroll\": \"自动滚动到底部\",\n \"logs.copied\": \"已复制!\",\n \"logs.copyAll\": \"复制全部日志\",\n \"logs.clear\": \"清空日志控制台\",\n \"logs.noSearchResults\": \"未找到匹配的日志行\",\n \"logs.empty\": \"暂无日志输出,启动服务后即可查看\",\n \"diagnostic.binaryTitle\": \"二进制文件未找到\",\n \"diagnostic.binarySuggestionBuild\": \"确认 CCX 二进制已构建: cd backend-go && make build\",\n \"diagnostic.binarySuggestionCheckDataDir\": \"检查 Desktop 数据目录中是否存在 ccx-go / ccx-go.exe\",\n \"diagnostic.binarySuggestionDownload\": \"首次使用需先构建后端,或从 Release 页面下载预编译版本\",\n \"diagnostic.portTitle\": \"端口冲突\",\n \"diagnostic.portSuggestionInstance\": \"检查是否有其他 CCX 实例已在运行\",\n \"diagnostic.portSuggestionEnv\": \"修改 .env 中 PORT 字段使用其他端口\",\n \"diagnostic.portSuggestionInspect\": \"使用 lsof -i :3688 (macOS/Linux) 或 netstat -ano | findstr :3688 (Windows) 检查端口占用\",\n \"diagnostic.healthTitle\": \"健康检查超时\",\n \"diagnostic.healthSuggestionLogs\": \"查看日志面板中是否有启动错误信息\",\n \"diagnostic.healthSuggestionEnv\": \"检查 .env 配置是否有语法错误\",\n \"diagnostic.healthSuggestionChannels\": \"确认上游渠道配置正确,首次启动可能需要较长时间\",\n \"diagnostic.healthSuggestionRestart\": \"尝试手动重启服务\",\n \"diagnostic.permissionTitle\": \"权限不足\",\n \"diagnostic.permissionSuggestionDataDir\": \"检查数据目录是否有写入权限\",\n \"diagnostic.permissionSuggestionExecutable\": \"macOS/Linux: 确认二进制文件有执行权限 (chmod +x)\",\n \"diagnostic.permissionSuggestionWindows\": \"Windows: 尝试以管理员身份运行\",\n \"diagnostic.genericTitle\": \"启动失败\",\n \"diagnostic.genericSuggestionLogs\": \"查看下方日志面板获取详细错误信息\",\n \"diagnostic.genericSuggestionRestart\": \"尝试重启服务\",\n \"setup.errorEmptyKey\": \"PROXY_ACCESS_KEY 不能为空\",\n \"env.saveSuccessHint\": \".env 已保存,重启服务后生效\",\n \"env.openedInEditor\": \"已在编辑器中打开 .env 文件\",\n \"sidebar.versionHintStore\": \"Microsoft Store 版本由 Store 自动更新\",\n \"sidebar.versionHintTray\": \"通过托盘菜单检查更新\",\n \"sidebar.versionClickCheck\": \"点击检查更新\",\n \"sidebar.updateAvailable\": \"新版 {version}\",\n \"sidebar.updateAvailableHint\": \"点击查看发布说明\",\n \"sidebar.updateInstallHint\": \"点击安装更新\",\n \"sidebar.updateOpenReleaseHint\": \"点击查看发布说明\",\n \"sidebar.theme\": \"主题\",\n \"sidebar.themeAuto\": \"跟随系统\",\n \"sidebar.themeLight\": \"亮色\",\n \"sidebar.themeDark\": \"暗色\",\n \"app.tabs.messages\": \"渠道管理\",\n \"app.tabs.conversations\": \"驾驶舱\",\n \"console.nativeChannelsEyebrow\": \"原生渠道编排\",\n \"console.nativeChannelsTitle\": \"WebUI 对齐协议工作区\",\n \"console.quickAdd\": \"快速添加\",\n \"console.quickAddEyebrow\": \"渠道预设\",\n \"console.quickAddTitle\": \"快速添加渠道\",\n \"console.actions.deleting\": \"正在删除...\",\n \"common.refreshing\": \"刷新中\",\n \"common.copy\": \"复制\",\n \"common.copied\": \"已复制\",\n \"common.delete\": \"删除\",\n \"common.add\": \"添加\",\n \"orchestration.searchPlaceholder\": \"搜索渠道...\",\n \"orchestration.noMatchingChannels\": \"没有匹配的渠道\",\n \"app.actions.refresh\": \"刷新\",\n \"app.actions.ping\": \"测试延迟\",\n \"app.actions.addChannel\": \"添加渠道\",\n \"orchestration.noActiveChannels\": \"暂无渠道,点击上方按钮添加\",\n \"orchestration.multiChannel\": \"多渠道模式\",\n \"orchestration.singleChannel\": \"单渠道模式\",\n \"orchestration.failoverSequence\": \"故障转移序列\",\n \"orchestration.standbyPool\": \"备用资源池\",\n \"orchestration.enable\": \"启用\",\n \"channelCard.suspended\": \"暂停\",\n \"channelCard.actions\": \"操作\",\n \"orchestration.moveToPool\": \"移至备用池\",\n \"orchestration.cache\": \"缓存\",\n \"orchestration.cacheWriteHigh\": \"缓存写偏高\",\n \"orchestration.cacheWriteHighHint\": \"15 分钟内缓存写入占比偏高,可能存在缓存命中配置不合理的问题。\",\n \"chart.globalStats\": \"用量统计\",\n \"status.normal\": \"正常\",\n \"status.paused\": \"暂停\",\n \"status.tripped\": \"熔断\",\n \"status.disabled\": \"禁用\",\n \"channelCard.apiKeyManagement\": \"API密钥管理\",\n \"channelCard.configuredKeys\": \"已配置的密钥\",\n \"orchestration.edit\": \"编辑\",\n \"capability.startTest\": \"开始测试\",\n \"orchestration.logs\": \"日志\",\n \"orchestration.copyConfig\": \"复制配置\",\n \"orchestration.openWebsite\": \"打开官网\",\n \"orchestration.pause\": \"暂停\",\n \"orchestration.resume\": \"恢复\",\n \"orchestration.promotion\": \"抢优先级 (5分钟)\",\n \"orchestration.delete\": \"删除\",\n \"orchestration.resumeReset\": \"恢复 (重置指标)\",\n \"orchestration.moveTop\": \"置顶\",\n \"orchestration.moveBottom\": \"置底\",\n \"orchestration.keepOne\": \"(至少保留一个)\",\n \"orchestration.deleteActiveGuard\": \"无法删除:故障转移序列中至少需要保留一个活跃渠道\",\n \"channelCard.disabledKeys\": \"已拉黑密钥\",\n \"channelCard.blacklistReason.authentication_error\": \"认证失败\",\n \"channelCard.blacklistReason.permission_error\": \"权限不足\",\n \"channelCard.blacklistReason.insufficient_balance\": \"余额不足\",\n \"channelCard.blacklistReason.insufficient_quota\": \"额度已耗尽\",\n \"channelCard.blacklistReason.unavailable\": \"不可用\",\n \"channelCard.blacklistReason.rate_limited\": \"频率限制\",\n \"channelCard.blacklistReason.invalid\": \"密钥无效\",\n \"channelCard.blacklistReason.unknown\": \"未知原因\",\n \"channelEditor.title.create\": \"添加渠道\",\n \"channelEditor.title.edit\": \"编辑渠道\",\n \"channelEditor.managed.providerLabel\": \"渠道提供商\",\n \"channelEditor.managed.officialChannel\": \"{provider} 官方渠道\",\n \"channelEditor.managed.officialBadge\": \"官方托管\",\n \"channelEditor.managed.editSubtitle\": \"{provider} 官方渠道 · 管理账号凭证\",\n \"channelEditor.managed.providerChannel\": \"{provider} 托管渠道\",\n \"channelEditor.managed.managedBadge\": \"自动托管\",\n \"channelEditor.managed.providerEditSubtitle\": \"{provider} 托管渠道 · 管理账号凭证\",\n \"channelEditor.managed.customEditSubtitle\": \"自动托管渠道 · 管理名称与账号凭证\",\n \"channelEditor.nav.basic\": \"基础信息\",\n \"channelEditor.basic.name.label\": \"名称\",\n \"channelEditor.basic.name.required\": \"渠道名称必填\",\n \"channelEditor.basic.serviceType.label\": \"上游类型\",\n \"channelEditor.basic.serviceType.placeholder\": \"选择上游类型\",\n \"channelEditor.basic.serviceType.required\": \"请选择上游类型\",\n \"channelEditor.basic.description.label\": \"描述\",\n \"channelEditor.nav.connection\": \"连接\",\n \"channelEditor.basic.baseUrl.label\": \"Base URL\",\n \"channelEditor.basic.baseUrl.required\": \"至少需要一个 Base URL\",\n \"channelEditor.basic.additionalUrls.label\": \"额外 URL(每行一个)\",\n \"channelEditor.transport.proxyUrl.label\": \"代理通道 URL\",\n \"channelEditor.transport.routePrefix.label\": \"接口路由前缀\",\n \"channelEditor.transport.skipTls.label\": \"跳过 TLS 验证\",\n \"channelEditor.nav.auth\": \"认证管理\",\n \"channelEditor.auth.keys.label\": \"API Keys\",\n \"channelEditor.auth.disabledKeys.label\": \"已禁用密钥\",\n \"channelEditor.auth.restoreKey\": \"恢复\",\n \"channelEditor.auth.copyKey\": \"复制密钥\",\n \"channelEditor.auth.deleteKey\": \"删除密钥\",\n \"channelEditor.auth.addKey\": \"添加 Key\",\n \"channelEditor.auth.historicalKeys\": \"历史密钥记录\",\n \"channelEditor.advanced.authHeader.label\": \"认证头\",\n \"channelEditor.advanced.authHeader.hint\": \"覆盖上游 API Key 发送方式;自动模式保持现有兼容逻辑\",\n \"channelEditor.advanced.authHeader.auto\": \"自动\",\n \"addChannel.testCapability\": \"测试能力\",\n \"addChannel.oneClickSetup\": \"一键配置\",\n \"addChannel.commonFilters\": \"常用过滤器\",\n \"addChannel.autoConvertModelNames\": \"自动转换模型名称\",\n \"addChannel.channelName\": \"渠道名称\",\n \"addChannel.apiKeys\": \"API Keys\",\n \"addChannel.detectedKeys\": \"已检测到 {count} 个 API Key\",\n \"addChannel.enterApiKey\": \"请输入至少一个 API Key\",\n \"copilotOAuth.quickAddKeyHint\": \"无需粘贴 API Key,创建后登录 GitHub Copilot 授权\",\n \"addChannel.newChannelPlacementLabel\": \"添加到队列末尾\",\n \"addChannel.expectedRequest\": \"预期请求\",\n \"addChannel.apiKeyLoadBalance\": \"可添加多个密钥作为故障转移备用\",\n \"addChannel.checking\": \"检测中...\",\n \"addChannel.modelsCount\": \"models {statusCode} ({count} 个)\",\n \"addChannel.supportedModelsLabel\": \"支持的模型 (可选)\",\n \"addChannel.supportedModelsPlaceholder\": \"输入规则后按回车添加,如 gpt-4*、*image*、!*image*\",\n \"addChannel.supportedModelsHint\": \"匹配客户端请求的原始模型名(模型重定向发生在渠道选中之后)。留空表示支持所有模型。支持精确匹配、prefix*、*suffix、*contains* 以及 ! 排除规则。\",\n \"addChannel.supportedModelsInvalidPattern\": \"已忽略非法规则。仅支持精确匹配、prefix*、*suffix、*contains* 和 ! 排除规则。\",\n \"addChannel.contextCapabilityTitle\": \"上下文能力\",\n \"addChannel.defaultContextWindowLabel\": \"默认上下文窗口 tokens\",\n \"addChannel.defaultContextWindowHint\": \"当实际模型不在内置库或 JSON 覆盖中时使用;留空则走未知模型策略。\",\n \"addChannel.defaultMaxOutputLabel\": \"默认最大输出 tokens\",\n \"addChannel.defaultMaxOutputHint\": \"仅在请求显式设置输出上限时参与过滤。\",\n \"addChannel.modelCapabilitiesJsonLabel\": \"实际模型能力 JSON\",\n \"addChannel.modelCapabilitiesJsonHint\": \"key 使用模型重定向后的实际模型名,支持通配符;空对象表示不覆盖内置能力。\",\n \"addChannel.modelCapabilitiesJsonInvalid\": \"请输入合法的 JSON 对象,值只能包含 contextWindowTokens、maxOutputTokens、thinkingMode、reasoningEfforts。\",\n \"addChannel.allowUnknownContextLabel\": \"允许未知模型承载大上下文\",\n \"addChannel.allowUnknownContextHint\": \"默认未知能力模型只允许 200K 以内请求;启用后大上下文也可候选。\",\n \"addChannel.visionFallbackLabel\": \"视觉回退模型\",\n \"addChannel.visionFallbackPlaceholder\": \"含图请求时使用的替代模型\",\n \"addChannel.visionFallbackHint\": \"当请求包含图片且目标模型不支持 vision 时,使用此模型替代。留空则 failover 到下一个渠道。\",\n \"addChannel.visionFallbackReasoningLabel\": \"回退思考强度\",\n \"addChannel.visionFallbackReasoningHint\": \"含图请求改用视觉回退模型时使用的思考强度。\",\n \"channelEditor.auth.apiKeyRequired\": \"至少需要一个 API Key\",\n \"channelEditor.mapping.reasoningEffort.label\": \"推理强度\",\n \"channelEditor.compat.noVisionModels\": \"无视觉模型(每行一个)\",\n \"channelEditor.compat.visionFallback.label\": \"视觉回退模型\",\n \"channelEditor.mapping.fetchModels\": \"获取模型列表\",\n \"channelEditor.mapping.fetchingModels\": \"拉取中...\",\n \"channelEditor.mapping.modelFetchNeedsConfig\": \"需要 Base URL 和 API Key 才能获取模型列表\",\n \"channelEditor.compat.visionEnabled\": \"视觉已启用\",\n \"channelEditor.compat.visionDisabled\": \"视觉已禁用\",\n \"channelEditor.mapping.models\": \"模型\",\n \"channelEditor.mapping.configured.label\": \"已配置\",\n \"channelEditor.mapping.supportedModels\": \"支持的模型(每行一个,留空=全部)\",\n \"addChannel.modelHintTip\": \"点击目标模型输入框会自动获取上游支持的模型列表,每个 API Key 的检测状态会显示在密钥列表中\",\n \"addChannel.modelMappingHintChat\": \"配置模型名称映射,将请求中的模型名重定向到目标模型。例如:将 \\\"gpt-4o\\\" 重定向到 \\\"gpt-5.4\\\"\",\n \"addChannel.modelMappingHintGemini\": \"配置模型名称映射,将请求中的模型名重定向到目标模型。例如:将 \\\"gemini-pro\\\" 重定向到 \\\"gemini-3.5-flash\\\"\",\n \"addChannel.modelMappingHintResponses\": \"配置模型名称映射,将请求中的模型名重定向到目标模型。例如:将 \\\"opus\\\" 重定向到 \\\"gpt-5.4\\\"\",\n \"addChannel.modelMappingHintVectors\": \"配置 Embeddings 模型名称映射,将请求中的向量模型名重定向到目标模型。例如:将 \\\"text-embedding-3-small\\\" 重定向到 \\\"jina-embeddings-v2-base-zh\\\"\",\n \"addChannel.modelMappingHintMessages\": \"配置模型名称映射,将请求中的模型名重定向到目标模型。例如:将 \\\"opus\\\" 重定向到 \\\"claude-sonnet-4-5-20250929\\\"\",\n \"addChannel.targetModelPlaceholderChat\": \"例如:gpt-5.4\",\n \"addChannel.targetModelPlaceholderResponses\": \"例如:gpt-5.4\",\n \"addChannel.targetModelPlaceholderGemini\": \"例如:gemini-3.5-flash\",\n \"addChannel.targetModelPlaceholderVectors\": \"例如:text-embedding-3-small\",\n \"addChannel.targetModelPlaceholderMessages\": \"例如:claude-opus-4-6\",\n \"addChannel.sourceModelNameTooLong\": \"自定义模型名过长(最多 50 字符)\",\n \"addChannel.sourceModelNoSpaces\": \"模型名不能包含空格\",\n \"addChannel.sourceModelInvalidChars\": \"模型名只能包含字母、数字、连字符、下划线、点、斜杠、冒号、@ 和 +\",\n \"addChannel.fillBaseUrlAndApiKey\": \"请先填写 Base URL 和至少一个 API Key\",\n \"addChannel.allApiKeysModelsFailed\": \"所有 API Key 都无法获取模型列表,请检查 API 密钥列表中的错误信息\",\n \"channelEditor.compat.title\": \"协议规范化\",\n \"channelEditor.compat.diagnose\": \"诊断\",\n \"channelEditor.compat.diagnoseApplied\": \"已应用 {count} 项推荐配置\",\n \"channelEditor.compat.diagnoseNoChange\": \"当前配置已是最优,无需调整\",\n \"channelEditor.compat.diagnoseFailed\": \"诊断失败,请检查渠道配置\",\n \"channelEditor.compat.diagnoseAppliedShort\": \"已应用 {count} 项\",\n \"channelEditor.compat.diagnoseNoChangeShort\": \"已最优\",\n \"channelEditor.compat.diagnoseFailedShort\": \"失败\",\n \"channelEditor.compat.reasoningStyle.label\": \"Reasoning 参数样式\",\n \"channelEditor.compat.textVerbosity.label\": \"输出冗长度\",\n \"channelEditor.compat.flags\": \"高级选项\",\n \"channelEditor.compat.noVision.label\": \"跳过含图请求\",\n \"channelEditor.compat.fastMode.label\": \"快速模式\",\n \"channelEditor.transport.lowQuality.label\": \"低质量渠道\",\n \"channelEditor.compat.injectDummySignature.label\": \"注入 Gemini 思考标记\",\n \"channelEditor.compat.stripThoughtSignature.label\": \"剥离 Gemini 思考标记\",\n \"channelEditor.compat.normalizeSystem.label\": \"规范化 System 角色域\",\n \"channelEditor.compat.normalizeUserId.label\": \"规范化用户 ID\",\n \"channelEditor.compat.stripBillingHeader.label\": \"剔除 CCH 计费尾缀\",\n \"channelEditor.runtime.autoBlacklist.label\": \"自动熔断/黑名单余额异常 Key\",\n \"channelEditor.compat.codexCompat.label\": \"Codex 工具兼容模式\",\n \"channelEditor.compat.stripCodexTools.label\": \"移除 Codex 客户端工具\",\n \"channelEditor.nav.custom\": \"自定义参数\",\n \"channelEditor.transport.requestTimeout.label\": \"请求超时 (s)\",\n \"channelEditor.transport.requestTimeout.invalid\": \"请求超时必须是 1-300 秒之间的整数\",\n \"channelEditor.transport.responseHeaderTimeout.label\": \"响应头等待超时 (s)\",\n \"channelEditor.transport.responseHeaderTimeout.invalid\": \"响应头等待超时必须是 1-300 秒之间的整数\",\n \"channelEditor.streamTimeout.title\": \"流式断流超时控制\",\n \"channelEditor.streamTimeout.firstContent.overrideLabel\": \"自定义首字等待超时\",\n \"channelEditor.streamTimeout.firstContent.label\": \"首字等待\",\n \"channelEditor.streamTimeout.inactivity.overrideLabel\": \"自定义断流超时\",\n \"channelEditor.streamTimeout.inactivity.label\": \"首字后断流\",\n \"channelEditor.streamTimeout.toolCallIdle.overrideLabel\": \"自定义工具调用空闲超时\",\n \"channelEditor.streamTimeout.toolCallIdle.label\": \"工具调用空闲\",\n \"channelEditor.streamTimeout.overrideHint\": \"自定义值将覆盖此渠道的全局流式超时设置\",\n \"channelEditor.streamTimeout.inheritHint\": \"禁用表示此渠道继承全局流式超时设置\",\n \"channelEditor.actions.cancel\": \"取消\",\n \"channelEditor.actions.create\": \"创建\",\n \"channelEditor.actions.save\": \"保存\",\n \"channelEditor.error.channelNotFound\": \"未找到渠道配置\",\n \"channelEditor.basic.website.label\": \"网站\",\n \"channelEditor.basic.baseUrl.placeholder\": \"每行一个,第一行为主地址,其余作为故障转移\\nhttps://api.example.com\\nhttps://backup.example.com\",\n \"channelEditor.mapping.redirect.title\": \"模型重定向\",\n \"channelEditor.nav.advanced\": \"高级选项\",\n \"channelEditor.nav.outline\": \"配置大纲\",\n \"channelEditor.runtime.title\": \"运行期策略\",\n \"channelEditor.transport.title\": \"代理路由网络\",\n \"channelEditor.rateLimit.title\": \"上游主动限速流量阀\",\n \"channelEditor.compat.fastMode.hint\": \"Codex /fast mode:在支持的模型上提升生成速度,适合更快编码迭代;会以更高倍率消耗额度或使用限制\",\n \"channelEditor.compat.textVerbosity.style\": \"文本详细程度样式\",\n \"channelEditor.compat.vision.title\": \"视觉控制\",\n \"channelEditor.compat.selectDefault\": \"默认\",\n \"channelEditor.compat.autoBlacklistBalance.label\": \"自动熔断/黑名单余额异常 Key\",\n \"channelEditor.compat.autoBlacklistBalance.hint\": \"自动禁用余额异常的 Key\",\n \"channelEditor.runtime.autoLearnRateLimits.label\": \"自动学习上游限速\",\n \"channelEditor.runtime.autoLearnRateLimits.hint\": \"解析 Retry-After / x-ratelimit-* 响应头动态调整 cooldown\",\n \"channelEditor.rateLimit.rpm.label\": \"每分钟最大请求量 (RPM)\",\n \"channelEditor.rateLimit.rpm.placeholder\": \"不设限制\",\n \"channelEditor.rateLimit.window.label\": \"检测窗口滑动时间 (s)\",\n \"channelEditor.rateLimit.window.placeholder\": \"60 秒\",\n \"channelEditor.rateLimit.maxConcurrent.label\": \"全双工最大并发数限制\",\n \"channelEditor.rateLimit.maxConcurrent.placeholder\": \"不设限制\",\n \"channelEditor.streamTimeout.preset.gentle\": \"宽松\",\n \"channelEditor.streamTimeout.preset.balanced\": \"平衡\",\n \"channelEditor.streamTimeout.preset.aggressive\": \"激进\",\n \"channelEditor.streamTimeout.firstContent.hint\": \"未响应则自动断开\",\n \"channelEditor.streamTimeout.inactivity.hint\": \"生成中途卡顿超时\",\n \"channelEditor.streamTimeout.toolCallIdle.hint\": \"FunctionCall 延迟超时\",\n \"channelEditor.streamTimeout.timeoutThreshold\": \"超时阈值\",\n \"channelEditor.mapping.supportedModels.label\": \"限定可支持模型范围(白名单模式,留空表示不限制)\",\n \"channelEditor.mapping.source.duplicate\": \"该源模型已存在映射\",\n \"channelEditor.basic.info.title\": \"基础信息\",\n \"channelEditor.connection.title\": \"连接终点\",\n \"channelEditor.basic.multiLineFailover\": \"多行实现故障轮换\",\n \"channelEditor.basic.expectedEndpoint\": \"预期终点:\",\n \"channelEditor.basic.description.placeholder\": \"可选填,用于备注此节点供应商...\",\n \"channelEditor.mapping.hint\": \"拦截调用请求中的 Source 别名并定向投递至上游 Target 真实模型\",\n \"channelEditor.compat.visionFallback.hint\": \"已通过模型重定向行的视觉开关标记禁用视觉模型;这些模型遇到图像输入时会自动切换到此模型处理\",\n \"channelEditor.mapping.addRedirect\": \"添加新重定向\",\n \"channelEditor.compat.generationParams\": \"生成参数\",\n \"channelEditor.compat.textVerbosity.placeholder\": \"默认\",\n \"channelEditor.mapping.modelScope\": \"模型范围\",\n \"channelEditor.compat.noVision.hint\": \"启用后,包含图片的请求将跳过此渠道并 failover 到下一个渠道\",\n \"channelEditor.compat.historicalImageLimit.label\": \"历史图片轮次限制\",\n \"channelEditor.compat.historicalImageLimit.hint\": \"0 表示不限制;填写 2-10 时,超过限制的历史图片会被替换为占位符\",\n \"channelEditor.compat.compactModel.label\": \"Compact 专用模型\",\n \"channelEditor.compat.compactModel.hint\": \"本地 compact(压缩摘要)时使用的上游模型名,不经过模型映射。对非 Responses 协议渠道生效;对 Responses 渠道,在原生 compact 失败时回退使用。\",\n \"channelEditor.compat.stripThoughtSignature.hint\": \"移除 functionCall 中的 thought_signature 字段\",\n \"channelEditor.compat.injectDummySignature.hint\": \"为第三方 API 注入假签名\",\n \"channelEditor.compat.reasoningStyle.hint\": \"选择 OpenAI 风格请求使用 reasoning.effort 还是 reasoning_effort\",\n \"channelEditor.compat.codexCompat.hint\": \"启用 Codex CLI 兼容模式\",\n \"channelEditor.compat.normalizeSystem.hint\": \"将 messages 数组里的 system 消息抽取回顶层 system 字段。国产模型官方直连(DeepSeek、Kimi、GLM、MiniMax 等)多仅支持 user/assistant,通常需要开启;标准 Anthropic 端点或完整中转站一般无需开启。建议先点「诊断」自动检测。\",\n \"channelEditor.compat.normalizeUserId.hint\": \"扁平化 metadata 中的 user ID\",\n \"channelEditor.compat.stripBillingHeader.hint\": \"移除 CCH 计费后缀(非官方域名默认开启)\",\n \"channelEditor.transport.lowQuality.hint\": \"标记此渠道为低质量,作为最后手段\",\n \"channelEditor.runtime.autoBlacklist.hint\": \"自动禁用余额异常的 Key\",\n \"channelEditor.transport.skipTls.hint\": \"禁用上游 TLS 证书验证\",\n \"channelEditor.transport.proxyUrl.hint\": \"代理上游请求的通道\",\n \"channelEditor.transport.routePrefix.hint\": \"接口路由前缀\",\n \"channelEditor.transport.requestTimeout.hint\": \"非流式上游请求总超时;留空继承全局\",\n \"channelEditor.transport.responseHeaderTimeout.hint\": \"连接后等待上游 HTTP 响应头;慢启动渠道可单独放宽\",\n \"channelEditor.rateLimit.section.label\": \"主动限速\",\n \"channelEditor.rateLimit.section.hint\": \"在请求发往上游前主动限流,避免触发上游 429。\",\n \"channelEditor.rateLimit.rpm.hint\": \"每分钟请求数限制\",\n \"channelEditor.rateLimit.burst.label\": \"突发容量\",\n \"channelEditor.rateLimit.burst.hint\": \"令牌桶容量;留空时自动取每分钟请求数。\",\n \"channelEditor.rateLimit.maxConcurrent.hint\": \"全双工并发流限制\",\n \"addChannel.duplicateKey\": \"重复密钥\",\n \"addChannel.addNewApiKeyPlaceholder\": \"输入完整的API密钥\",\n \"addChannel.customHeadersLabel\": \"自定义请求头 (可选)\",\n \"addChannel.customHeadersHint\": \"添加或覆盖发送到上游的 HTTP 请求头\",\n \"addChannel.streamFirstContentTimeoutLabel\": \"首字等待超时\",\n \"addChannel.streamInactivityTimeoutLabel\": \"首字后断流超时\",\n \"addChannel.streamToolCallIdleTimeoutLabel\": \"工具调用空闲超时\",\n \"addChannel.streamTimeoutStrategyLabel\": \"流式超时策略\",\n \"addChannel.streamTimeoutStrategyInherit\": \"继承全局\",\n \"addChannel.streamTimeoutOverrideHint\": \"自定义值会覆盖本渠道的全局流式超时。\",\n \"addChannel.streamTimeoutInheritHint\": \"关闭表示本渠道继承全局流式超时。\",\n \"addChannel.headerNameLabel\": \"Header 名称\",\n \"addChannel.headerValueLabel\": \"请求头值\",\n \"addChannel.descriptionLabel\": \"描述 (可选)\",\n \"addChannel.descriptionHint\": \"可选的渠道描述...\",\n \"addChannel.skipTlsLabel\": \"跳过 TLS 证书验证\",\n \"addChannel.skipTlsHint\": \"仅在自签名或域名不匹配时临时启用,生产环境请关闭\",\n \"addChannel.lowQualityLabel\": \"低质量渠道\",\n \"addChannel.lowQualityHint\": \"启用后强制本地估算 token 数量,偏差超过 5% 时使用本地值\",\n \"addChannel.quickMode\": \"快速粘贴\",\n \"addChannel.quickHint\": \"粘贴 Base URL、API Key 或完整配置片段,自动识别并填入表单。\",\n \"addChannel.applyDetected\": \"应用识别结果\",\n \"addChannel.serviceTypeManual\": \"手动选择\",\n \"addChannel.serviceTypeDetected\": \"已自动识别\",\n \"addChannel.serviceTypeDefault\": \"使用默认类型\",\n \"addChannel.serviceTypeRecommendedSuffix\": \" · 推荐\",\n \"addChannel.noneDetected\": \"未识别\",\n \"addChannel.fullSubtitle\": \"配置渠道、密钥、模型与高级协议选项\",\n \"addChannel.editSubtitle\": \"修改渠道配置信息\",\n \"cockpit.searchPlaceholder\": \"搜索会话...\",\n \"cockpit.allKinds\": \"所有类型\",\n \"cockpit.refresh\": \"刷新\",\n \"cockpit.total\": \"共 {count} 个会话\",\n \"cockpit.empty\": \"暂无活跃会话。请求经过网关后,会话会出现在驾驶舱雷达上。\",\n \"cockpit.noMatches\": \"没有匹配的会话\",\n \"cockpit.overrideTitle\": \"会话渠道覆盖\",\n \"cockpit.overrideHint\": \"选择该会话使用的渠道序列(按优先级排列):\",\n \"cockpit.overrideSequence\": \"覆盖序列\",\n \"cockpit.overrideRequired\": \"请至少选择一个渠道\",\n \"cockpit.saveOverride\": \"保存覆盖\",\n \"cockpit.copyRawUserId\": \"复制对话 ID\",\n \"cockpit.rawUserIdCopied\": \"对话 ID 已复制\",\n \"cockpit.rawUserIdCopyFailed\": \"复制失败\",\n \"cockpit.tooltip.statusStreaming\": \"状态:正在流式响应\",\n \"cockpit.tooltip.statusActive\": \"状态:处理中\",\n \"cockpit.tooltip.statusIdle\": \"状态:空闲\",\n \"cockpit.tooltip.statusUnknown\": \"状态:未知\",\n \"cockpit.tooltip.kind\": \"请求类型:{kind}\",\n \"cockpit.tooltip.requests\": \"请求轮次:{count}\",\n \"cockpit.tooltip.duration\": \"持续时间:{duration}\",\n \"cockpit.tooltip.subagents\": \"子代理数量:{count}\",\n \"cockpit.tooltip.parentConversation\": \"跳转到主对话:{id}\",\n \"cockpit.tooltip.parentThread\": \"父线程:{id}\",\n \"cockpit.tooltip.childConversation\": \"跳转到子代理:{id}\",\n \"cockpit.tooltip.hiddenChannels\": \"展开查看剩余 {count} 个渠道\",\n \"cockpit.tooltip.restoreDefault\": \"恢复默认渠道顺序\",\n \"cockpit.tooltip.moreSubagents\": \"展开查看剩余 {count} 个子代理\",\n \"cockpit.tooltip.clearSubagentOverride\": \"清除子代理渠道指定\",\n \"cockpit.tooltip.quickCurrentChannel\": \"点击固定当前渠道\",\n \"cockpit.tooltip.quickNextOverride\": \"下一次覆盖目标\",\n \"cockpit.tooltip.quickSetNext\": \"固定为此对话优先渠道\",\n \"cockpit.tooltip.quickResumeAndSetNext\": \"恢复并固定此渠道\",\n \"cockpit.tooltip.quickPinned\": \"已固定为此对话优先渠道,再次点击取消固定\",\n \"cockpit.tooltip.currentChannel\": \"当前正在使用的渠道\",\n \"cockpit.tooltip.nextChannel\": \"下一次将使用的覆盖目标\",\n \"cockpit.tooltip.nextChannelTripped\": \"下一目标已熔断\",\n \"cockpit.tooltip.pausedChannel\": \"渠道已暂停,不参与调度\",\n \"cockpit.tooltip.circuitOpen\": \"熔断已打开,暂时跳过该渠道\",\n \"cockpit.tooltip.channelAlreadyFirst\": \"{name} 已在渠道顺序最前\",\n \"cockpit.tooltip.moveChannelToTop\": \"将 {name} 移到渠道顺序最前\",\n \"cockpit.tooltip.demoteChannel\": \"将 {name} 移到渠道顺序末尾\",\n \"cockpit.tooltip.demoteChannelDisabled\": \"已经是最后一个渠道\",\n \"cockpit.tooltip.columnCount\": \"{column}:{count} 个会话\",\n \"cockpit.overrideActivePerpetual\": \"正在使用自定义渠道顺序(手动恢复前不会自动过期)\",\n \"cockpit.overrideActive\": \"正在使用自定义渠道顺序,{time} 后自动恢复默认调度\",\n \"cockpit.restoreDefault\": \"恢复默认顺序\",\n \"cockpit.overrideDuration\": \"空闲自动恢复\",\n \"cockpit.noChannelsForKind\": \"该类型暂无可用渠道\",\n \"cockpit.board.streaming\": \"流式中\",\n \"cockpit.board.streamingHint\": \"正在流式输出的会话\",\n \"cockpit.board.working\": \"工作中\",\n \"cockpit.board.workingHint\": \"存在活跃工作的主对话\",\n \"cockpit.board.subagents\": \"子代理\",\n \"cockpit.board.subagentsHint\": \"包含子代理的会话\",\n \"cockpit.board.active\": \"活跃\",\n \"cockpit.board.activeHint\": \"仍在活跃但未流式输出的会话\",\n \"cockpit.board.idle\": \"空闲\",\n \"cockpit.board.idleHint\": \"当前空闲的会话\",\n \"cockpit.relation.parent\": \"主对话\",\n \"cockpit.relation.parentThread\": \"父线程 {id}\",\n \"cockpit.relation.children\": \"子代理 {count}\",\n \"cockpit.active\": \"活跃: {count}\",\n \"cockpit.override\": \"覆盖: {count}\",\n \"cockpit.mainConversation\": \"主对话\",\n \"cockpit.recap\": \"Recap\",\n \"cockpit.detail.requests\": \"请求轮次\",\n \"cockpit.detail.duration\": \"持续时间\",\n \"cockpit.mainRouting\": \"主对话渠道\",\n \"cockpit.subagentRouting\": \"子代理渠道\",\n \"cockpit.durationDefault\": \"30 分钟(默认)\",\n \"cockpit.duration15min\": \"15 分钟\",\n \"cockpit.duration1hour\": \"1 小时\",\n \"cockpit.duration2hours\": \"2 小时\",\n \"cockpit.duration4hours\": \"4 小时\",\n \"cockpit.duration8hours\": \"8 小时\",\n \"cockpit.duration12hours\": \"12 小时\",\n \"cockpit.duration24hours\": \"24 小时\",\n \"cockpit.durationNever\": \"永不恢复\",\n \"cockpit.subagentFollowMain\": \"跟随主对话\",\n \"cockpit.subagentOverride\": \"已指定渠道\",\n \"cockpit.subagentClearOverride\": \"清除\",\n \"capability.title\": \"能力测试 - {channel}\",\n \"capability.cancel\": \"取消测试\",\n \"console.capability.running\": \"测试进行中...\",\n \"capability.noResults\": \"尚未进行能力测试\",\n \"console.capability.lastResults\": \"上次测试结果\",\n \"capability.modelsLabel\": \"模型:\",\n \"capability.duration\": \"总耗时\",\n \"console.capability.protocolResults\": \"协议结果\",\n \"console.capability.compatible\": \"兼容协议\",\n \"capability.loadingTitle\": \"正在测试协议兼容性...\",\n \"capability.cancelling\": \"取消中...\",\n \"capability.cancelled\": \"已取消\",\n \"capability.partial\": \"部分可用\",\n \"capability.noCompatibleProtocols\": \"无兼容协议\",\n \"capability.modelQueued\": \"排队中\",\n \"capability.protocolRunning\": \"测试中\",\n \"capability.rpmLabel\": \"测试 RPM\",\n \"capability.progressSummary\": \"{done}/{total} 模型已完成\",\n \"capability.supported\": \"支持\",\n \"capability.unsupported\": \"不支持\",\n \"capability.table.protocol\": \"协议\",\n \"capability.table.status\": \"状态\",\n \"capability.table.successCount\": \"成功数/总数\",\n \"capability.table.latency\": \"延迟\",\n \"capability.table.streaming\": \"流式\",\n \"capability.table.actions\": \"操作\",\n \"capability.copyToTab\": \"复制到此 Tab\",\n \"capability.currentTab\": \"当前 Tab\",\n \"capability.convert\": \"{protocol} 转换\",\n \"capability.modelDetailsUnavailable\": \"模型详情暂不可用\",\n \"capability.tooltipLatency\": \"延迟\",\n \"capability.tooltipStreaming\": \"流式\",\n \"capability.modelStatus\": \"状态\",\n \"capability.actualModel\": \"实际模型\",\n \"capability.codexImageGeneration\": \"Codex 生图工具\",\n \"capability.codexImageGenerationSummary\": \"{supported}/{total} 个 Key 支持\",\n \"capability.codexImageGenerationStripped\": \"已通过剥离工具兼容\",\n \"capability.retryModel\": \"重测此模型\",\n \"capability.testModel\": \"点击测试\",\n \"capability.runModeReusedRunning\": \"复用进行中任务\",\n \"capability.runModeResumedCancelled\": \"恢复已取消任务\",\n \"capability.runModeCacheHit\": \"缓存结果\",\n \"capability.runModeReusedPreviousResults\": \"复用上次成功结果\",\n \"capability.lastResults\": \"上次测试结果\",\n \"capability.models\": \"模型\",\n \"capability.snapshotUpdated\": \"更新时间:{time}\",\n \"channelLogs.title\": \"频道日志\",\n \"channelLogs.status.pending\": \"等待中\",\n \"channelLogs.status.firstByte\": \"首字节\",\n \"channelLogs.status.streaming\": \"流式传输\",\n \"channelLogs.status.completed\": \"已完成\",\n \"channelLogs.status.failed\": \"失败\",\n \"channelLogs.status.cancelled\": \"已取消\",\n \"channelLogs.sourceCapabilityTest\": \"能力测试\",\n \"channelLogs.copyEntry\": \"复制此条日志\",\n \"channelLogs.copiedEntry\": \"已复制日志\",\n \"channelLogs.reasoning.original\": \"原始\",\n \"channelLogs.reasoning.actual\": \"发出\",\n \"channelLogs.retry\": \"重试\",\n \"channelLogs.duration.connect\": \"连接\",\n \"channelLogs.duration.firstByte\": \"首字节\",\n \"channelLogs.duration.total\": \"总耗时\",\n \"console.logs.model\": \"模型\",\n \"console.logs.baseUrl\": \"URL\",\n \"console.logs.key\": \"Key\",\n \"channelLogs.status.connecting\": \"连接中\",\n \"console.logs.duration\": \"耗时\",\n \"console.logs.error\": \"错误\",\n \"console.logs.entries\": \"条\",\n \"channelLogs.empty\": \"暂无日志\",\n \"toast.channelCopied\": \"渠道已复制到 {protocol} Tab\",\n \"toast.copyFailed\": \"复制渠道失败: {message}\",\n \"tooltip.circuitBreakerSettings\": \"调校台\",\n \"chart.close\": \"关闭\",\n \"chart.1h\": \"1h\",\n \"chart.6h\": \"6h\",\n \"chart.24h\": \"24h\",\n \"chart.today\": \"今日\",\n \"chart.7d\": \"1周\",\n \"chart.30d\": \"1月\",\n \"chart.90d\": \"3月\",\n \"chart.180d\": \"半年\",\n \"chart.365d\": \"1年\",\n \"chart.thisyear\": \"今年\",\n \"chart.traffic\": \"流量\",\n \"chart.tokens\": \"Token\",\n \"chart.cost\": \"花费\",\n \"chart.cache\": \"Cache\",\n \"chart.cacheRw\": \"Cache R/W\",\n \"chart.cacheRead\": \"Cache Read\",\n \"chart.cacheWrite\": \"Cache Write\",\n \"chart.totalRequests\": \"总请求数\",\n \"chart.requests\": \"请求数\",\n \"chart.successRate\": \"成功率\",\n \"chart.inputTokens\": \"输入 Token\",\n \"chart.outputTokens\": \"输出 Token\",\n \"chart.input\": \"输入\",\n \"chart.output\": \"输出\",\n \"chart.requestUnit\": \"请求\",\n \"chart.tokenUnit\": \"token\",\n \"chart.issueCount\": \"异常\",\n \"chart.total\": \"合计\",\n \"chart.totalCost\": \"总花费\",\n \"chart.collapse\": \"收起\",\n \"chart.expandModelStats\": \"展开模型统计\",\n \"chart.expandChart\": \"展开图表\",\n \"chart.noData\": \"暂无请求数据\",\n \"chart.noChannelMetrics\": \"暂无 {channel} 的指标数据\",\n \"chart.noModelData\": \"暂无模型统计数据\",\n \"chart.noModelRequestsInRange\": \"选定时间范围内没有模型请求记录\",\n \"chart.noKeyUsageInRange\": \"选定时间范围内没有 Key 使用数据\",\n \"chart.historyLoadFailed\": \"加载历史数据失败\",\n \"chart.modelStatsLoadFailed\": \"加载模型统计失败\",\n \"chart.globalStatsLoadFailed\": \"加载全局统计失败\",\n \"chart.noRequestsInRange\": \"选定时间范围内没有请求记录\",\n \"channelEditor.title.editSubtitle\": \"修改渠道配置\",\n \"channelEditor.nav.redirect\": \"模型重定向\",\n \"channelEditor.basic.name.placeholder\": \"例如 OpenAI 生产环境\",\n \"channelEditor.basic.website.placeholder\": \"https://openai.com\",\n \"channelEditor.auth.duplicateKey\": \"重复的 API Key\",\n \"channelEditor.auth.addNewApiKey.placeholder\": \"输入新的 API Key\",\n \"channelEditor.mapping.source.label\": \"源模型\",\n \"channelEditor.mapping.source.placeholder\": \"gpt-4o\",\n \"channelEditor.mapping.target.label\": \"目标模型\",\n \"channelEditor.mapping.target.placeholder\": \"claude-sonnet-4-6\",\n \"console.form.presets\": \"预设快速注入\",\n \"console.form.existingMappings\": \"已有条目映射\",\n \"console.form.mappings\": \"条\",\n \"console.form.toggleVision\": \"切换视觉通道\",\n \"console.form.visionFallback\": \"视觉回退配置\",\n \"console.form.addNewMapping\": \"添加新映射关系\",\n \"console.form.createMapping\": \"建立映射\",\n \"channelEditor.transport.proxyUrl.placeholder\": \"socks5://...\",\n \"channelEditor.transport.requestTimeout.placeholder\": \"60\",\n \"channelEditor.transport.responseHeaderTimeout.placeholder\": \"60\",\n \"channelEditor.transport.routePrefix.placeholder\": \"kimi\",\n \"channelEditor.rateLimit.window.hint\": \"滑动窗口时长\",\n \"channelEditor.streamTimeout.strategy\": \"策略\",\n \"channelEditor.streamTimeout.strategyInherit\": \"继承全局\",\n \"channelEditor.timeout.title\": \"超时策略\",\n \"channelEditor.timeout.hint\": \"全部以秒为单位;关闭表示继承全局设置\",\n \"channelEditor.toast.updated\": \"渠道已更新\",\n \"channelEditor.toast.added\": \"渠道已添加\",\n \"console.form.existingHeaders\": \"已配置标头\",\n \"console.form.addNewHeader\": \"添加新标头\",\n \"console.form.headerValuePlaceholder\": \"标头携带对应的 Value 内容...\",\n \"console.form.headersNote\": \"说明\",\n \"console.form.headersNoteInject\": \"自定义 Headers 将在每个请求中注入到上游\",\n \"console.form.headersNoteOverride\": \"支持覆盖默认 Headers(如 User-Agent、Authorization 等)\",\n \"console.form.headersNoteTemplate\": \"键名和值都支持模板变量(如果后端实现了变量替换)\",\n \"capability.fetchingModels\": \"正在获取模型列表...\",\n \"capability.selectModels\": \"选择要测试的模型\",\n \"capability.selectAll\": \"全选\",\n \"capability.deselectAll\": \"取消全选\",\n \"capability.noModelsFound\": \"未找到模型,请在下方手动添加\",\n \"capability.addModelLabel\": \"添加模型\",\n \"capability.addModelPlaceholder\": \"例如 claude-sonnet-4-6\",\n \"capability.notStarted\": \"待测试\",\n \"capability.compatibleProtocols\": \"兼容协议\",\n \"capability.modelDetails\": \"模型明细\",\n \"capability.attemptedModels\": \"尝试模型数: {count}\",\n \"capability.startedAt\": \"开始时间\",\n \"capability.testedAt\": \"完成时间\",\n \"capability.success\": \"成功\",\n \"capability.failed\": \"失败\",\n \"capability.failedTooltip\": \"测试失败\",\n \"capability.channelFallback\": \"渠道 #{id}\",\n \"capability.modelRunning\": \"测试中\",\n \"capability.modelSuccess\": \"可用\",\n \"capability.modelFailed\": \"失败\",\n \"capability.modelSkipped\": \"已跳过\",\n \"capability.reasonNotRun\": \"本轮未执行\",\n \"capability.reasonCancelled\": \"完成前已取消\",\n \"capability.reasonTimeout\": \"请求超时\",\n \"capability.noApiKeyError\": \"该渠道未配置 API Key,无法执行能力测试。\",\n \"capability.genericJobError\": \"能力测试失败:{message}\",\n \"capability.redirectTests\": \"模型重定向测试\",\n \"capability.redirectTestsDesc\": \"跨协议模型映射验证\",\n \"addChannel.quickInputPlaceholder\": \"粘贴配置片段,自动识别 Base URL 和 API Key(支持多行)\\nhttps://api.example.com\\nhttps://api-backup.example.com\\nsk-ant-api03-xxxxxxxxxxxxxxxx\\nsk-ant-api03-yyyyyyyyyyyyyyyy\",\n \"addChannel.modelListEmptyHint\": \"暂无模型候选。填写 Base URL 和 API Key 后,聚焦模型输入框会自动从 /models 获取。\",\n \"addChannel.modelCapabilitiesRowsHint\": \"模型重定向的目标模型会自动补到这里并优先套用内置预设;透传或未重定向的模型也可以手动添加。\",\n \"addChannel.modelCapabilityRedirectTargets\": \"重定向目标\",\n \"addChannel.modelCapabilitiesConfigured\": \"已配置模型能力\",\n \"addChannel.modelCapabilityModelLabel\": \"实际模型\",\n \"addChannel.modelCapabilityModelPlaceholder\": \"选择或输入上游实际模型\",\n \"addChannel.contextTokensShort\": \"上下文\",\n \"addChannel.outputTokensShort\": \"输出\",\n \"addChannel.thinkingModeLabel\": \"思考模式\",\n \"addChannel.reasoningEffortsLabel\": \"思考强度\",\n \"addChannel.modelCapabilityBuiltinMatched\": \"预设:{pattern}\",\n \"addChannel.modelCapabilitiesRowsInvalid\": \"请检查模型能力行,模型名不能为空,tokens 和价格必须是非负数。\",\n \"addChannel.inputCacheHitPriceLabel\": \"缓存命中输入价\",\n \"addChannel.inputCacheMissPriceLabel\": \"缓存未命中输入价\",\n \"addChannel.outputPriceLabel\": \"输出价\",\n \"addChannel.pricingTitle\": \"费率设定\",\n \"addChannel.pricingCurrencyLabel\": \"币种\",\n \"addChannel.pricingUnitLabel\": \"单位\",\n \"addChannel.defaultOutputTokensMeta\": \"默认输出 {tokens}\",\n \"addChannel.recommendedOutputTokensMeta\": \"推荐输出 {tokens}\",\n \"addChannel.modelPricingUnitHint\": \"每 100 万 tokens\",\n \"addChannel.embeddingCompatibilityTitle\": \"Embedding 兼容性\",\n \"addChannel.embeddingCompatibilityHint\": \"key 使用模型重定向后的实际上游模型名;fallback 只会在相同 embeddingSpaceId/实际模型、维度和归一化语义内发生。\",\n \"addChannel.embeddingCompatibilityRedirectTargets\": \"重定向目标\",\n \"addChannel.embeddingCompatibilityConfigured\": \"已配置 Embedding 空间\",\n \"addChannel.embeddingCapabilityModelLabel\": \"实际模型\",\n \"addChannel.embeddingCapabilityModelPlaceholder\": \"选择或输入上游 Embedding 模型\",\n \"addChannel.embeddingSpaceIdLabel\": \"向量空间 ID\",\n \"addChannel.embeddingSpaceIdPlaceholder\": \"留空时使用实际模型名\",\n \"addChannel.embeddingDimensionsLabel\": \"默认维度\",\n \"addChannel.embeddingSupportedDimensionsLabel\": \"支持的请求维度\",\n \"addChannel.embeddingSupportedDimensionsPlaceholder\": \"例如 1024, 1536\",\n \"addChannel.embeddingNormalizedLabel\": \"归一化\",\n \"addChannel.embeddingNormalizedUnknown\": \"未指定\",\n \"addChannel.embeddingNormalizedTrue\": \"已归一化\",\n \"addChannel.embeddingNormalizedFalse\": \"未归一化\",\n \"addChannel.embeddingCapabilitiesRowsInvalid\": \"请检查 Embedding 兼容性行:实际模型不能为空,维度必须为正整数,支持维度列表只能包含正整数。\",\n \"channelDiscovery.title\": \"发现可用配置\",\n \"channelDiscovery.hint\": \"用当前 Base URL 和 API Key 直接探测模型、协议、重定向和适配开关。\",\n \"channelDiscovery.button\": \"发现配置\",\n \"channelDiscovery.failed\": \"发现配置失败\",\n \"channelDiscovery.missingBaseUrl\": \"请先填写 Base URL。\",\n \"channelDiscovery.missingApiKey\": \"请先填写 API Key。\",\n \"channelDiscovery.missingServiceType\": \"请先选择服务类型。\",\n \"channelDiscovery.recommendedKind\": \"推荐 {kind}\",\n \"channelDiscovery.modelsFound\": \"模型 {count}\",\n \"channelDiscovery.mapping\": \"推荐重定向\",\n \"channelDiscovery.reasoning\": \"推荐思考强度\",\n \"channelDiscovery.reasoningNote\": \"思考强度是按源模型角色给出的默认值,发现流程目前不逐档测试。\",\n \"channelDiscovery.compat\": \"适配开关\",\n \"channelDiscovery.capabilities\": \"能力验证\",\n \"channelDiscovery.capabilityToolCalls\": \"工具调用\",\n \"channelDiscovery.capabilityVision\": \"图片输入\",\n \"channelDiscovery.capabilityImageGeneration\": \"Codex 生图工具\",\n \"channelDiscovery.capabilityThinkingPassback\": \"思考回传\",\n \"channelDiscovery.capabilitySupported\": \"支持\",\n \"channelDiscovery.capabilityUnsupported\": \"不支持\",\n \"channelDiscovery.capabilityRequired\": \"需要\",\n \"channelDiscovery.capabilityNotRequired\": \"不需要\",\n \"channelDiscovery.apply\": \"应用推荐\",\n \"channelDiscovery.applied\": \"已应用发现推荐,请检查后保存\",\n \"autopilot.quickAdd.alreadyAdded\": \"该 Base URL 已添加为渠道「{name}」,提交后会自动追加新密钥。\",\n \"autopilot.costPreference.balanced\": \"均衡\",\n \"autopilot.costPreference.cost_first\": \"成本优先\",\n \"autopilot.costPreference.quality_first\": \"质量优先\",\n \"autopilot.costPreferenceDesc.balanced\": \"在质量和成本之间取得平衡。\",\n \"autopilot.costPreferenceDesc.cost_first\": \"优先选择低成本渠道,适当容忍质量下降。\",\n \"autopilot.costPreferenceDesc.quality_first\": \"优先选择高质量供应商,容忍较高成本。\",\n \"autopilot.mode.active\": \"活跃\",\n \"autopilot.mode.assist\": \"辅助\",\n \"autopilot.mode.auto\": \"自动\",\n \"autopilot.mode.dry_run\": \"试运行\",\n \"autopilot.mode.off\": \"关闭\",\n \"autopilot.mode.shadow\": \"影子\",\n \"autopilot.modeDesc.assist\": \"辅助模式:重排候选列表顺序,但不改变最终选择。\",\n \"autopilot.modeDesc.auto\": \"全自动模式:过滤 + 重排,满足准入门槛后接管调度。\",\n \"autopilot.modeDesc.off\": \"完全关闭智能路由,不计算不记录路由 trace。\",\n \"autopilot.modeDesc.shadow\": \"只计算并记录路由决策,不影响真实调度(推荐)。\",\n \"autopilot.modePanel.confirmMessage\": \"即将切换到 {mode} 模式。该模式会影响真实调度结果,请确认您了解其行为。是否继续?\",\n \"autopilot.readiness.ready\": \"自动模式已达到上线门槛\",\n \"autopilot.readiness.collecting\": \"正在积累自动模式安全样本\",\n \"autopilot.readiness.progress\": \"有效样本 {samples}/{requiredSamples},连续观测 {hours}/{requiredHours} 小时\",\n \"autopilot.readiness.successRate\": \"成功率\",\n \"autopilot.readiness.fallbackRate\": \"Fallback\",\n \"autopilot.readiness.failOpenRate\": \"Fail-open\",\n \"autopilot.readiness.lastRollback\": \"{time} 检测到连续 SLO 回归,系统已自动从 auto 降回 assist。\",\n \"autopilot.readiness.reason.telemetry_unavailable\": \"路由遥测不可用\",\n \"autopilot.readiness.reason.telemetry_query_failed\": \"路由遥测查询失败\",\n \"autopilot.readiness.reason.insufficient_samples\": \"有效样本不足\",\n \"autopilot.readiness.reason.insufficient_observation_time\": \"连续观测时间不足\",\n \"autopilot.readiness.reason.success_rate_below_threshold\": \"成功率低于 95%\",\n \"autopilot.readiness.reason.fallback_rate_above_threshold\": \"Fallback 率高于 10%\",\n \"autopilot.readiness.reason.failopen_rate_above_threshold\": \"Fail-open 率高于 2%\",\n \"autopilot.readiness.reason.insufficient_comparison_samples\": \"近期与基线对比样本不足\",\n \"autopilot.readiness.reason.recent_success_regression\": \"近期成功率明显下降\",\n \"autopilot.readiness.reason.recent_fallback_regression\": \"近期 Fallback 率明显上升\",\n \"autopilot.readiness.reason.recent_latency_regression\": \"近期 p95 延迟明显上升\",\n \"autopilot.modePanel.confirmTitle\": \"确认切换模式\",\n \"autopilot.modePanel.costPreference\": \"价格偏好\",\n \"autopilot.modePanel.killSwitch\": \"急停开关(Kill Switch)\",\n \"autopilot.modePanel.killSwitchActive\": \"急停开关已激活,智能路由已强制关闭。请先关闭急停开关以启用其他模式。\",\n \"autopilot.modePanel.killSwitchHint\": \"开启后强制关闭智能路由,优先级高于路由模式选择。\",\n \"autopilot.modePanel.reset\": \"重置\",\n \"autopilot.modePanel.routingMode\": \"路由模式\",\n \"autopilot.modePanel.save\": \"保存配置\",\n \"autopilot.modePanel.title\": \"全局策略\",\n \"autopilot.title\": \"智能路由 Autopilot\",\n \"autopilot.traceStats.mismatchRate\": \"不一致率\",\n \"autopilot.traceStats.mismatches\": \"不一致数\",\n \"autopilot.traceStats.modeDistribution\": \"按模式分布\",\n \"autopilot.traceStats.taskClassDistribution\": \"按任务类别分布\",\n \"autopilot.traceStats.title\": \"路由追踪统计\",\n \"autopilot.traceStats.total\": \"总追踪数\",\n \"autopilot.traceTable.candidates\": \"候选渠道\",\n \"autopilot.traceTable.col.kind\": \"类型\",\n \"autopilot.traceTable.col.match\": \"一致\",\n \"autopilot.traceTable.col.mode\": \"模式\",\n \"autopilot.traceTable.col.outcome\": \"结果\",\n \"autopilot.traceTable.col.model\": \"模型\",\n \"autopilot.traceTable.col.shadowVsActual\": \"Shadow vs 实际\",\n \"autopilot.traceTable.col.taskClass\": \"任务类别\",\n \"autopilot.traceTable.col.time\": \"时间\",\n \"autopilot.traceTable.empty\": \"暂无追踪记录\",\n \"autopilot.traceTable.mismatchOnly\": \"只看不一致\",\n \"autopilot.traceTable.no\": \"否\",\n \"autopilot.traceTable.sortReasons\": \"排序原因\",\n \"autopilot.traceTable.title\": \"路由决策追踪\",\n \"autopilot.traceTable.yes\": \"是\",\n \"healthCenter.col.channel\": \"渠道\",\n \"healthCenter.col.endpoints\": \"Endpoints\",\n \"healthCenter.col.expand\": \"详情\",\n \"healthCenter.col.kind\": \"类型\",\n \"healthCenter.col.status\": \"状态\",\n \"healthCenter.col.successRate\": \"成功率\",\n \"healthCenter.detail.baseUrl\": \"Base URL\",\n \"healthCenter.detail.confidence\": \"置信度\",\n \"healthCenter.detail.consecutiveFail\": \"连续失败\",\n \"healthCenter.detail.evidence\": \"证据\",\n \"healthCenter.detail.keyHash\": \"Key\",\n \"healthCenter.detail.lastSuccess\": \"最近成功\",\n \"healthCenter.detail.p95\": \"P95 延迟\",\n \"healthCenter.detail.firstByteP95\": \"P95 首字节\",\n \"healthCenter.detail.qualityTier\": \"质量\",\n \"healthCenter.detail.speedTier\": \"速度\",\n \"healthCenter.detail.stabilityTier\": \"稳定性\",\n \"healthCenter.detail.successRate15m\": \"成功率(15m)\",\n \"healthCenter.detail.successRate1h\": \"成功率(1h)\",\n \"healthCenter.detail.suggestedAction\": \"建议操作\",\n \"healthCenter.detail.updatedAt\": \"更新时间\",\n \"healthCenter.kind.chat\": \"Chat\",\n \"healthCenter.kind.gemini\": \"Gemini\",\n \"healthCenter.kind.images\": \"Images\",\n \"healthCenter.kind.messages\": \"Messages\",\n \"healthCenter.kind.responses\": \"Responses\",\n \"healthCenter.kind.vectors\": \"Vectors\",\n \"healthCenter.noChannels\": \"暂无渠道健康数据\",\n \"healthCenter.state.dead\": \"死亡\",\n \"healthCenter.state.degraded\": \"降级\",\n \"healthCenter.state.healthy\": \"健康\",\n \"healthCenter.state.limited\": \"限流\",\n \"healthCenter.state.misconfigured\": \"疑似配置错\",\n \"healthCenter.state.unknown\": \"未知\",\n \"healthCenter.title\": \"渠道健康中心\",\n \"healthCenter.totalChannels\": \"渠道总数\",\n \"healthCenter.totalEndpoints\": \"Endpoint 总数\",\n \"nav.health\": \"健康中心\",\n \"nav.healthDesc\": \"渠道健康状态总览\",\n \"nav.autopilot\": \"智能路由\",\n \"nav.autopilotDesc\": \"自动托管调度策略\",\n \"tab.healthTitle\": \"渠道健康中心\",\n \"tab.autopilotTitle\": \"智能路由 Autopilot\",\n \"channelHealth.stateLabel\": \"健康状态\",\n \"channelHealth.endpointsHealthy\": \"个端点健康\",\n \"channelHealth.avgSuccessRate\": \"平均成功率\",\n \"channelHealth.inconsistentWarning\": \"{count} 个端点状态不一致\",\n \"healthCenter.changelog.title\": \"最近变更\",\n \"healthCenter.changelog.empty\": \"暂无变更记录\",\n \"healthCenter.changelog.live\": \"实时\",\n \"healthCenter.changelog.connecting\": \"连接中…\",\n \"healthCenter.changelog.disconnected\": \"实时更新已断开,正在重连…\",\n \"healthCenter.changelog.type.healthChanged\": \"健康状态变化\",\n \"healthCenter.changelog.type.profileUpdated\": \"画像更新\",\n \"healthCenter.changelog.type.discoveryCompleted\": \"自动发现完成\",\n \"healthCenter.changelog.type.autoMappingApplied\": \"自动写入模型配置\",\n \"channelHealth.originOfficial\": \"官方 API\",\n \"channelHealth.originRelay\": \"中转\",\n \"channelHealth.originCommunity\": \"公益\",\n \"channelHealth.originUnknown\": \"来源未知\",\n \"channelHealth.poolFree\": \"白嫖池\",\n \"channelHealth.poolTemp\": \"临时池\",\n \"cockpitOverview.title\": \"驾驶舱总览\",\n \"cockpitOverview.health\": \"健康概览\",\n \"cockpitOverview.channels\": \"渠道数\",\n \"cockpitOverview.endpoints\": \"Endpoint 数\",\n \"cockpitOverview.subscriptions\": \"订阅概览\",\n \"cockpitOverview.totalSubscriptions\": \"订阅总数\",\n \"cockpitOverview.manualIntents\": \"人工意图\",\n \"cockpitOverview.activeIntents\": \"活跃意图\",\n \"cockpitOverview.totalIntents\": \"意图总数\",\n \"cockpitOverview.todoList\": \"待办事项\",\n \"cockpitOverview.empty\": \"暂无数据,请先配置渠道和订阅。\",\n \"cockpitOverview.recommendations\": \"渠道推荐\",\n \"cockpitOverview.noRecommendations\": \"暂无推荐,用量数据积累后会在此展示建议。\",\n \"cockpitOverview.usageCount\": \"近期使用 {count} 次\",\n \"cockpitOverview.currentChannel\": \"当前主用渠道\",\n \"cockpitOverview.recommendedChannel\": \"推荐尝试\",\n \"cockpitOverview.scoreDelta\": \"综合分差 +{delta}\",\n \"cockpitOverview.activeTrials\": \"活跃试用意图\",\n \"cockpitOverview.noActiveTrials\": \"暂无活跃试用意图。可在渠道中心点击“试用”创建。\",\n \"cockpitOverview.trialExpired\": \"已到期\",\n \"cockpitOverview.trialStats\": \"命中 {hit} · 成功率 {rate} · 回退 {fallback}\",\n \"cockpitOverview.trialStatsLabel\": \"试用统计\",\n \"cockpitOverview.remaining\": \"剩余时间\",\n \"cockpitOverview.status\": \"状态\",\n \"manualIntent.field.intentType\": \"意图类型\",\n \"manualIntent.field.channelUid\": \"渠道 UID(可选)\",\n \"manualIntent.field.model\": \"模型(可选)\",\n \"manualIntent.intentType.model_trial\": \"模型试用\",\n \"manualIntent.intentType.channel_trial\": \"渠道试用\",\n \"manualIntent.intentType.endpoint_trial\": \"Endpoint 试用\",\n \"manualIntent.intentType.session_pin\": \"会话锁定\",\n \"manualIntent.status.active\": \"活跃\",\n \"manualIntent.status.expired\": \"已到期\",\n \"manualIntent.status.exhausted\": \"已耗尽\",\n \"manualIntent.status.disabled\": \"已停用\",\n \"manualIntent.actions.end\": \"结束\",\n \"manualIntent.field.channelKind\": \"渠道类型\",\n \"manualIntent.field.name\": \"名称(可选)\",\n \"manualIntent.field.metricsKey\": \"Endpoint 指标键(可选)\",\n \"manualIntent.field.mappedModel\": \"映射模型(可选)\",\n \"manualIntent.field.sessionId\": \"会话 ID\",\n \"manualIntent.field.agentRoles\": \"Agent 角色\",\n \"manualIntent.field.taskClasses\": \"任务类别\",\n \"manualIntent.field.trafficPercent\": \"流量比例\",\n \"manualIntent.field.ttlMinutes\": \"有效时长(分钟)\",\n \"manualIntent.field.maxRequests\": \"最大请求数\",\n \"manualIntent.field.maxEstimatedCost\": \"最大预估成本($)\",\n \"manualIntent.field.fallbackOnFailure\": \"失败时回退\",\n \"manualIntent.field.requireHardConstraints\": \"强制硬性约束\",\n \"manualIntent.field.reason\": \"备注\",\n \"manualIntent.agentRole.main\": \"主 Agent\",\n \"manualIntent.agentRole.subagent\": \"子 Agent\",\n \"manualIntent.taskClass.supervisor\": \"监督\",\n \"manualIntent.taskClass.worker\": \"工作\",\n \"manualIntent.taskClass.lightweight\": \"轻量\",\n \"manualIntent.taskClass.vision\": \"视觉\",\n \"manualIntent.taskClass.long_context\": \"长上下文\",\n \"manualIntent.taskClass.image_generation\": \"图像生成\",\n \"manualIntent.taskClass.embedding\": \"向量嵌入\",\n \"manualIntent.validation.channelKindRequired\": \"渠道类型为必填项\",\n \"manualIntent.validation.sessionIdRequired\": \"会话锁定需要填写会话 ID\",\n \"manualIntent.actions.create\": \"创建试用\",\n \"manualIntent.actions.cancel\": \"取消\",\n \"manualIntent.error.createFailed\": \"创建试用意图失败\"\n}\n"}, "e972d82e6b4b3edf23f948edbd0db2d4c80db655": {"t": 1785809900.09936, "n": 73558, "h": "3448c04858cf7ffd", "c": "{\n \"common.gatewayLabel\": \"CCX CORE\",\n \"common.online\": \"GATEWAY ONLINE\",\n \"common.connecting\": \"CONNECTING...\",\n \"common.offline\": \"GATEWAY OFFLINE\",\n \"common.autoGenerated\": \"自动生成\",\n \"common.refreshWebUI\": \"刷新 Web UI\",\n \"common.version\": \"当前版本\",\n \"common.gatewayPort\": \"网关端口\",\n \"common.daemonPid\": \"守护 PID\",\n \"common.autoStart\": \"开机自启\",\n \"common.autoStartOn\": \"已开启\",\n \"common.autoStartOff\": \"已关闭\",\n \"common.serviceHealthy\": \"运行正常\",\n \"common.serviceStarting\": \"网关启动中\",\n \"common.serviceDisconnected\": \"服务已断开\",\n \"common.settings\": \"设置\",\n \"common.save\": \"保存\",\n \"common.cancel\": \"取消\",\n \"common.clearSearch\": \"清除搜索\",\n \"common.retry\": \"重试\",\n \"adminApi.error.serviceNotRunning\": \"服务未运行,无法发送请求\",\n \"adminApi.error.networkUnavailable\": \"服务未运行或网络不可达,请检查后端是否已启动\",\n \"nav.status\": \"网关监控\",\n \"nav.statusDesc\": \"实时状态及核心日志\",\n \"nav.agent\": \"Agent 配置\",\n \"nav.agentDesc\": \"本地开发代理控制\",\n \"nav.channels\": \"渠道中心\",\n \"nav.channelsDesc\": \"协议级渠道编排\",\n \"nav.subscriptions\": \"订阅中心\",\n \"nav.subscriptionsDesc\": \"账号授权与订阅接入\",\n \"nav.cockpit\": \"驾驶舱\",\n \"nav.cockpitDesc\": \"会话与子代理看板\",\n \"nav.env\": \"环境参数\",\n \"nav.envDesc\": \"网关配置文件编辑\",\n \"nav.dashboard\": \"管理面板\",\n \"nav.dashboardDesc\": \"原生管理面板\",\n \"sidebar.managementSection\": \"管理\",\n \"tab.statusTitle\": \"网关状态监控\",\n \"tab.agentTitle\": \"Agent 代理配置\",\n \"tab.channelsTitle\": \"渠道管理控制台\",\n \"tab.subscriptionsTitle\": \"订阅中心\",\n \"tab.cockpitTitle\": \"驾驶舱\",\n \"tab.envTitle\": \"环境参数管理\",\n \"tab.dashboardTitle\": \"管理面板\",\n \"sidebar.language\": \"语言\",\n \"sidebar.languageEnglish\": \"English\",\n \"sidebar.languageChinese\": \"中文\",\n \"setup.loading\": \"正在初始化 CCX 控制台\",\n \"setup.title\": \"CCX Desktop 初始配置\",\n \"setup.description\": \"PROXY_ACCESS_KEY 是 AI Agent 通过 CCX 代理访问上游 API 的身份凭证,所有调用方必须持有此密钥。\",\n \"setup.regenerate\": \"重新生成\",\n \"setup.regenerateTitle\": \"重新生成随机密钥\",\n \"setup.hide\": \"隐藏\",\n \"setup.show\": \"显示\",\n \"setup.copied\": \"已复制\",\n \"setup.copyKey\": \"复制密钥\",\n \"setup.configPath\": \"配置文件路径\",\n \"setup.copyPath\": \"复制路径\",\n \"setup.hint\": \"保存后 CCX 将自动启动。后续可在主界面【环境参数】页继续调整其他配置。\",\n \"setup.saving\": \"正在保存并启动...\",\n \"setup.submit\": \"完成配置并启动\",\n \"webui.notRunning\": \"CCX 服务尚未启动,无法显示 Web UI。\",\n \"webui.openInBrowser\": \"浏览器打开\",\n \"metrics.gatewayPort\": \"网关端口\",\n \"metrics.uptime\": \"运行时长\",\n \"metrics.channels\": \"调度信道\",\n \"metrics.version\": \"网关版本\",\n \"actions.start\": \"启动服务\",\n \"actions.stop\": \"停止服务\",\n \"actions.restart\": \"重启服务\",\n \"actions.openWebUI\": \"打开管理面板\",\n \"actions.openBrowser\": \"浏览器直达\",\n \"actions.refreshStatus\": \"刷新当前状态\",\n \"details.title\": \"服务详情\",\n \"details.binary\": \"二进制\",\n \"details.binaryMissing\": \"未发现\",\n \"details.dataDir\": \"数据目录\",\n \"details.dataDirMissing\": \"未设置\",\n \"details.healthStatus\": \"健康状态\",\n \"details.revealDir\": \"打开所在目录\",\n \"details.openDir\": \"打开目录\",\n \"channel.headerEyebrow\": \"Channel Preset Center\",\n \"channel.title\": \"渠道中心\",\n \"channel.description\": \"统一把 DeepSeek、MiMo、Kimi、GLM、SenseNova、MiniMax、DashScope、讯飞星辰等 Key 可同时用于 Agent 直连和 CCX 统一渠道池,复杂开关由预设自动处理。\",\n \"channel.hasKey\": \"已有 Key\",\n \"channel.promo\": \"通过推广链接注册领取专属福利\",\n \"channel.console\": \"访问官方控制台\",\n \"channel.target\": \"添加目标\",\n \"channel.planLabel\": \"套餐 / Base URL\",\n \"channel.apiKeyLabel\": \"API Key\",\n \"channel.baseUrlPrefix\": \"Base URL\",\n \"channel.keySavedPlaceholder\": \"已保存,留空则复用该 Provider Key\",\n \"channel.keyInputPlaceholder\": \"输入 API Key,仅保存在本机 Desktop 配置中\",\n \"channel.missingKey\": \"请填写 API Key,或先在 Agent 配置中保存该 Provider 的 key。\",\n \"channel.reuseKey\": \"将复用本机已保存的 {provider} Key:{key}\",\n \"channel.name\": \"渠道名称\",\n \"channel.nameHint\": \"同名渠道会被直接覆盖更新;如需新建独立渠道,请改用不同名称。\",\n \"channel.presetWrites\": \"预设将自动写入\",\n \"channel.capabilityHint\": \"能力开关:reasoning / vision / model list / 兼容字段会按 Provider 自动配置。\",\n \"channel.addToCCX\": \"添加到 CCX\",\n \"channel.badgeDirectAgent\": \"Agent 直连\",\n \"channel.badgeNativeMessages\": \"Messages 原生\",\n \"copilotOAuth.description\": \"使用 GitHub OAuth 获取 Copilot 授权,授权成功后会把 GitHub OAuth token 添加为本渠道密钥。该 token 仅用于与 GitHub 交互,CCX 会自动换成短期 Copilot token。\",\n \"copilotOAuth.button\": \"使用 GitHub Copilot 登录\",\n \"copilotOAuth.userCode\": \"授权码\",\n \"copilotOAuth.openAuthorize\": \"打开 GitHub 授权\",\n \"copilotOAuth.waiting\": \"等待 GitHub 授权完成...\",\n \"copilotOAuth.success\": \"授权成功,GitHub OAuth token 已添加到本渠道。\",\n \"copilotOAuth.cancel\": \"取消授权\",\n \"copilotOAuth.expired\": \"授权码已过期,请重新发起授权。\",\n \"subscription.headerEyebrow\": \"Subscription\",\n \"subscription.title\": \"订阅中心\",\n \"subscription.description\": \"集中管理需要账号授权的订阅服务,并将授权结果添加为 CCX 渠道。\",\n \"subscription.authorized\": \"已授权\",\n \"subscription.copilotDescription\": \"使用 GitHub OAuth 获取 Copilot 授权,同一份授权可添加到 Claude、Chat、Codex、Gemini 任一协议渠道。该 token 仅用于与 GitHub 交互,CCX 会自动换成短期 Copilot token。\",\n \"subscription.checkingChannel\": \"正在检查现有 Copilot 渠道...\",\n \"subscription.verifying\": \"正在校验 GitHub 账号...\",\n \"subscription.addingCopilotAccount\": \"正在加入账号到所选渠道...\",\n \"subscription.authorizeCopilot\": \"授权 GitHub Copilot\",\n \"subscription.authorizeAndAddAccount\": \"授权并加入新账号\",\n \"subscription.addExistingAccount\": \"加入已授权账号\",\n \"subscription.copilotAuthorizationSavedOnly\": \"已获授权;当前未检测到 {target} 渠道,点击下方按钮即可添加。\",\n \"subscription.retryAddAccount\": \"重试加入账号\",\n \"subscription.accountsTitle\": \"{target} 已授权账号({count})\",\n \"subscription.accountCountShort\": \"{count} 个账号\",\n \"subscription.accountsEmpty\": \"当前 {target} 渠道还没有已授权账号。\",\n \"subscription.accountsSummary\": \"{target} 渠道已累积 {count} 个授权账号\",\n \"subscription.accountUnnamed\": \"未命名账号\",\n \"subscription.accountRemove\": \"移除该账号\",\n \"subscription.accountRemoveConfirm\": \"确认移除\",\n \"subscription.verifyFailed\": \"未能获取该 GitHub 账号信息,请重试\",\n \"subscription.channelResolveFailed\": \"渠道创建后未能定位,请重试\",\n \"subscription.targetLabel\": \"添加到协议渠道\",\n \"subscription.channelExists\": \"已存在\",\n \"subscription.targetClaude\": \"Claude\",\n \"subscription.targetClaudeDesc\": \"添加到 Messages 渠道,供 Claude Code 协议入口使用\",\n \"subscription.targetChat\": \"Chat\",\n \"subscription.targetChatDesc\": \"添加到 OpenAI Chat Completions 渠道\",\n \"subscription.targetCodex\": \"Codex\",\n \"subscription.targetCodexDesc\": \"添加到 Responses 渠道,供 Codex 协议入口使用\",\n \"subscription.targetGemini\": \"Gemini\",\n \"subscription.targetGeminiDesc\": \"添加到 Gemini 协议渠道\",\n \"subscription.field.uid\": \"订阅 ID\",\n \"subscription.field.name\": \"显示名称\",\n \"subscription.field.notes\": \"备注\",\n \"subscription.newApi.connect\": \"接入 new-api\",\n \"subscription.newApi.step1Title\": \"第一步:校验 Token\",\n \"subscription.newApi.step2Title\": \"第二步:确认接入信息\",\n \"subscription.newApi.baseUrl\": \"new-api 地址\",\n \"subscription.newApi.accessToken\": \"Access Token\",\n \"subscription.newApi.userId\": \"用户 ID(可选)\",\n \"subscription.newApi.authTokenMode\": \"Token 模式\",\n \"subscription.newApi.verify\": \"校验\",\n \"subscription.newApi.reVerify\": \"重新校验\",\n \"subscription.newApi.accountPreview\": \"账号预览\",\n \"subscription.newApi.username\": \"用户名\",\n \"subscription.newApi.quota\": \"额度\",\n \"subscription.newApi.usedQuota\": \"已用额度\",\n \"subscription.newApi.availableModels\": \"可用模型数\",\n \"subscription.newApi.groups\": \"分组倍率\",\n \"subscription.newApi.channelKind\": \"渠道类型\",\n \"subscription.newApi.channelName\": \"渠道名称(可选)\",\n \"subscription.newApi.provisionGroup\": \"接入分组(可选)\",\n \"subscription.newApi.provision\": \"接入\",\n \"subscription.newApi.provisionSuccess\": \"已自动创建密钥并接入渠道,密钥已写入渠道配置,不会再次显示。\",\n \"subscription.newApi.discoveryStarted\": \"模型发现已启动。\",\n \"channel.preset.deepseek.label\": \"DeepSeek\",\n \"channel.preset.deepseek.description\": \"V4-Pro / V4-Flash 双旗舰推理模型,突破性成本。完全开源(MIT),以极具竞争力的价格提供前沿级性能。\",\n \"channel.preset.mimo.label\": \"MiMo\",\n \"channel.preset.mimo.description\": \"小米自研 AI 平台,面向 Agent 时代打造。旗舰 MiMo-V2.5-Pro 具备万亿参数与百万上下文窗口,赋能小米跨设备、车、家的智能生态。\",\n \"channel.preset.compshare.label\": \"优云智算套餐\",\n \"channel.preset.compshare.description\": \"优云智算是 UCloud 旗下 AI 云平台,提供高性价比国内 AI 模型 Agent Plan 套餐,支持包月订阅或按量付费(49 元/月起),同时提供官方海外模型稳定接入,支持 Claude Code、Codex 与 API 集成,具备企业级高并发、7×24 技术支持和自助开票能力;通过推广链接注册可领取 5 元平台试用金。\",\n \"channel.preset.runapi.label\": \"RunAPI\",\n \"channel.preset.runapi.description\": \"RunAPI 是高效稳定的API OpenRouter平替平台,一个 API Key 即可访问 OpenAI、Claude、Gemini、DeepSeek、Grok 等 150+ 主流模型,低至 1 折,极其稳定,可以无缝兼容 Claude Code、OpenClaw 等工具。RunAPI 为 CCX用户提供专属福利:注册联系管理员即可领取¥7的免费额度\",\n \"channel.preset.kimi.label\": \"Kimi / Moonshot\",\n \"channel.preset.kimi.description\": \"Moonshot AI 智能助手平台。旗舰 Kimi K2.6 支持原生多模态,以超长上下文和 Agent Swarm 能力著称。\",\n \"channel.preset.glm.label\": \"GLM / BigModel\",\n \"channel.preset.glm.description\": \"清华系智谱 AI MaaS 平台。旗舰 GLM-5.1 在编程领域达到前沿模型水平,港股上市,全栈华为昇腾芯片训练。\",\n \"channel.preset.sensenova.label\": \"SenseNova\",\n \"channel.preset.sensenova.description\": \"商汤日日新 LLM API 平台,兼容 OpenAI 与 Anthropic 协议,预设使用 GLM-5.2、DeepSeek V4 Flash 与 SenseNova 6.7 Flash-Lite 多模态回退。\",\n \"channel.preset.minimax.label\": \"MiniMax\",\n \"channel.preset.minimax.description\": \"全球多模态 AI 领导者(港股上市)。旗舰 M3 具备百万上下文、原生视觉与 Agent 能力;海螺视频/音频模型位居全球前列;超 70% 收入来自海外市场。\",\n \"channel.preset.dashscope.label\": \"阿里云 DashScope\",\n \"channel.preset.dashscope.description\": \"阿里云一站式 LLM 平台,集成通义千问及第三方模型,全模态覆盖,提供按量、编程套餐与 Token Plan 选项。\",\n \"channel.preset.opencode-zen.label\": \"OpenCode Zen / Go\",\n \"channel.preset.opencode-zen.description\": \"OpenCode 官方精选模型网关,Go 订阅套餐与 Zen 按量套餐共用同一套编程模型预设。\",\n \"channel.preset.opencode-go.label\": \"OpenCode Go\",\n \"channel.preset.opencode-go.description\": \"低成本开源编程模型订阅服务($5/月起),支持 Messages、Chat、Responses 三种协议。\",\n \"channel.preset.deepseek.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.deepseek.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.deepseek.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.deepseek.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.mimo.plan.anthropic.label\": \"按量 (Anthropic)\",\n \"channel.preset.mimo.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.mimo.plan.openai-chat.label\": \"按量 (OpenAI)\",\n \"channel.preset.mimo.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.mimo.plan.token-cn.label\": \"Token Plan - 中国 (OpenAI)\",\n \"channel.preset.mimo.plan.token-cn.description\": \"中国区 Token Plan Chat / Responses 通用入口\",\n \"channel.preset.mimo.plan.token-sgp.label\": \"Token Plan - 新加坡 (OpenAI)\",\n \"channel.preset.mimo.plan.token-sgp.description\": \"新加坡区 Token Plan Chat / Responses 通用入口\",\n \"channel.preset.mimo.plan.token-ams.label\": \"Token Plan - 欧洲 (OpenAI)\",\n \"channel.preset.mimo.plan.token-ams.description\": \"欧洲区 Token Plan Chat / Responses 通用入口\",\n \"channel.preset.mimo.plan.token-cn-anthropic.label\": \"Token Plan - 中国 (Anthropic)\",\n \"channel.preset.mimo.plan.token-cn-anthropic.description\": \"中国区 Token Plan Claude Messages 原生入口\",\n \"channel.preset.mimo.plan.token-sgp-anthropic.label\": \"Token Plan - 新加坡 (Anthropic)\",\n \"channel.preset.mimo.plan.token-sgp-anthropic.description\": \"新加坡区 Token Plan Claude Messages 原生入口\",\n \"channel.preset.mimo.plan.token-ams-anthropic.label\": \"Token Plan - 欧洲 (Anthropic)\",\n \"channel.preset.mimo.plan.token-ams-anthropic.description\": \"欧洲区 Token Plan Claude Messages 原生入口\",\n \"channel.preset.compshare.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.compshare.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.compshare.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.compshare.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.runapi.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.runapi.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.runapi.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.runapi.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.unity2.label\": \"Unity2.ai\",\n \"channel.preset.unity2.description\": \"Unity2.ai 是面向个人开发者、团队、企业的高性能 AI 模型 API 中转平台,长期服务国内头部企业,日均承载超 300 亿 token 调用,支持 5000 RPM 级高并发。一个 API Key 即可适配 Claude Code、Codex、OpenAI 模型、IDE 插件和 Agent 工作流等场景。具备企业级稳定供应能力,在高并发、持续调用和团队集中采购场景下依然保持低延迟、高可用。现在注册 Unity2.ai 可领取 $2 余额,加入官方群再送 $10 余额,合计最高可领 $12 免费额度。\",\n \"channel.preset.unity2.plan.anthropic.label\": \"Anthropic 兼容\",\n \"channel.preset.unity2.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.unity2.plan.openai-chat.label\": \"OpenAI 兼容\",\n \"channel.preset.unity2.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.kimi.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.kimi.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.kimi.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.kimi.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.kimi.plan.coding-anthropic.label\": \"Coding Plan (Anthropic)\",\n \"channel.preset.kimi.plan.coding-anthropic.description\": \"Coding Plan Claude Messages 原生入口\",\n \"channel.preset.kimi.plan.coding-openai-chat.label\": \"Coding Plan (OpenAI)\",\n \"channel.preset.kimi.plan.coding-openai-chat.description\": \"Coding Plan Chat / Responses 通用入口\",\n \"channel.preset.glm.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.glm.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.glm.plan.coding.label\": \"Coding Plan (OpenAI)\",\n \"channel.preset.glm.plan.coding.description\": \"Coding Plan Chat / Responses 通用入口\",\n \"channel.preset.glm.plan.openai-chat.label\": \"通用 (OpenAI)\",\n \"channel.preset.glm.plan.openai-chat.description\": \"通用 Chat / Responses 入口\",\n \"channel.preset.sensenova.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.sensenova.plan.anthropic.description\": \"Claude Messages 兼容入口\",\n \"channel.preset.sensenova.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.sensenova.plan.openai-chat.description\": \"Chat 入口,Responses 由 CCX 转换\",\n \"channel.preset.minimax.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.minimax.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.minimax.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.minimax.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.dashscope.plan.anthropic.label\": \"按量 (Anthropic)\",\n \"channel.preset.dashscope.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.dashscope.plan.openai-chat.label\": \"按量 (OpenAI)\",\n \"channel.preset.dashscope.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.dashscope.plan.coding-anthropic.label\": \"Coding Plan (Anthropic)\",\n \"channel.preset.dashscope.plan.coding-anthropic.description\": \"Coding Plan Claude Messages 原生入口\",\n \"channel.preset.tencent-lkeap.label\": \"腾讯云 TokenHub\",\n \"channel.preset.tencent-lkeap.description\": \"腾讯云大模型服务平台,整合混元及 DeepSeek、Kimi 等主流模型,提供 Token Plan 订阅和按量计费,适配 Claude Code、Codex 等主流编程工具。\",\n \"channel.preset.tencent-lkeap.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.tencent-lkeap.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.tencent-lkeap.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.tencent-lkeap.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.kimi-code.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.kimi-code.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.kimi-code.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.kimi-code.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.volc-ark.label\": \"火山方舟 Coding Plan\",\n \"channel.preset.volc-ark.description\": \"字节跳动火山引擎旗下一站式 AI 平台,提供豆包系列模型精调、推理、评测全链路服务,覆盖多厂商模型,企业级保障完善。\",\n \"channel.preset.volc-ark.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.volc-ark.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.volc-ark.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.volc-ark.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.qianfan.label\": \"百度千帆 Coding Plan\",\n \"channel.preset.qianfan.description\": \"百度智能云企业级大模型平台,内置文心 5.0 原生全模态大模型,支持多智能体协同,提供从开发到部署的全链路工具。\",\n \"channel.preset.qianfan.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.qianfan.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.qianfan.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.qianfan.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.originrouter.label\": \"极易云 OriginRouter\",\n \"channel.preset.originrouter.description\": \"极易云统一模型 API 转发平台,一个密钥无缝切换 GPT、Claude、Gemini 等主流模型,高速稳定,适合多模型混用场景。\",\n \"channel.preset.originrouter.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.originrouter.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.originrouter.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.originrouter.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.dashscope.plan.coding-openai-chat.label\": \"Coding Plan (OpenAI)\",\n \"channel.preset.dashscope.plan.coding-openai-chat.description\": \"Coding Plan Chat / Responses 通用入口\",\n \"channel.preset.dashscope.plan.token-plan-anthropic.label\": \"Token Plan (Anthropic)\",\n \"channel.preset.dashscope.plan.token-plan-anthropic.description\": \"Token Plan Claude Messages 原生入口\",\n \"channel.preset.dashscope.plan.token-plan-openai-chat.label\": \"Token Plan (OpenAI)\",\n \"channel.preset.dashscope.plan.token-plan-openai-chat.description\": \"Token Plan Chat / Responses 通用入口\",\n \"channel.preset.opencode-zen.plan.go-openai-chat.label\": \"Go 订阅套餐\",\n \"channel.preset.opencode-zen.plan.go-openai-chat.description\": \"低成本订阅入口,Messages 经 CCX 转 Chat 上游,Chat / Responses 直接使用该入口\",\n \"channel.preset.opencode-zen.plan.openai-chat.label\": \"Zen 按量套餐\",\n \"channel.preset.opencode-zen.plan.openai-chat.description\": \"按量付费入口,Messages 经 CCX 转 Chat 上游,Chat / Responses 直接使用该入口\",\n \"channel.preset.opencode-go.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.opencode-go.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.opencode-go.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.opencode-go.plan.openai-chat.description\": \"Messages 经 CCX 转 Chat 上游,Chat / Responses 直接使用该入口\",\n \"channel.preset.modelscope.label\": \"ModelScope\",\n \"channel.preset.modelscope.description\": \"阿里达摩院开源模型社区,涵盖文本、图像、语音等领域 10,000+ 模型,提供模型探索、推理与训练全链路支持。\",\n \"channel.preset.modelscope.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.modelscope.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.modelscope.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.modelscope.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.xfyun.label\": \"讯飞星辰\",\n \"channel.preset.xfyun.description\": \"科大讯飞星辰 MaaS Astron Coding Plan,提供 OpenAI、Anthropic 与 Responses 三个独立 Coding Plan 入口。\",\n \"channel.preset.xfyun.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.xfyun.plan.anthropic.description\": \"Claude Messages Coding Plan 入口\",\n \"channel.preset.xfyun.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.xfyun.plan.openai-chat.description\": \"OpenAI Chat Coding Plan 入口\",\n \"channel.preset.xfyun.plan.responses.label\": \"Responses-compatible\",\n \"channel.preset.xfyun.plan.responses.description\": \"OpenAI Responses Coding Plan 入口\",\n \"channel.preset.openrouter.label\": \"OpenRouter\",\n \"channel.preset.openrouter.description\": \"最大规模 AI 模型聚合平台,单个 API Key 即可访问数百款模型。OpenAI 兼容协议,内置安全治理与成本控制能力。\",\n \"channel.preset.openrouter.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.openrouter.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.openrouter.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.openrouter.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.target.messages.label\": \"Claude Messages\",\n \"channel.target.messages.description\": \"Claude 原生 Messages 协议,支持 Claude Code 直连或通过 CCX 代理\",\n \"channel.target.responses.label\": \"Codex Responses\",\n \"channel.target.responses.description\": \"OpenAI Responses 协议,专供 Codex CLI 及兼容客户端使用\",\n \"channel.target.chat.label\": \"OpenAI Chat\",\n \"channel.target.chat.description\": \"OpenAI Chat Completions 协议,兼容各类 Chat 客户端和第三方工具\",\n \"env.title\": \"环境配置\",\n \"env.pathDetecting\": \"检测中\",\n \"env.refresh\": \"刷新\",\n \"env.openingEditor\": \"打开中…\",\n \"env.openWithEditor\": \"用 {editor} 打开\",\n \"env.openInEditor\": \"用编辑器打开\",\n \"env.selectEditor\": \"选择编辑器…\",\n \"env.save\": \"保存\",\n \"env.saving\": \"保存中\",\n \"env.saved\": \"已保存\",\n \"env.failed\": \"失败\",\n \"env.hide\": \"隐藏\",\n \"env.show\": \"显示\",\n \"env.copied\": \"已复制\",\n \"env.copy\": \"复制\",\n \"env.fieldRequired\": \"{field}不能为空\",\n \"env.fieldDisallow\": \"{field}不能使用示例占位值\",\n \"env.fieldNumber\": \"{field}必须是数字\",\n \"env.fieldInteger\": \"{field}必须是整数\",\n \"env.fieldMin\": \"{field}不能小于 {min}\",\n \"env.fieldMax\": \"{field}不能大于 {max}\",\n \"env.loadFailed\": \"加载配置失败:{error}\",\n \"env.saveSuccess\": \".env 已保存,重启服务后生效\",\n \"agent.statusDetecting\": \"检测中\",\n \"agent.statusConfigured\": \"已配置\",\n \"agent.statusPortMismatch\": \"端口不匹配\",\n \"agent.statusUnconfigured\": \"未配置\",\n \"agent.localGateway\": \"当前 CCX 网关\",\n \"agent.custom\": \"自定义\",\n \"agent.currentProvider\": \"当前 Provider\",\n \"agent.currentUrl\": \"当前 URL\",\n \"agent.targetUrl\": \"目标 URL\",\n \"agent.notSet\": \"未设置\",\n \"agent.configPath\": \"配置文件\",\n \"agent.authPath\": \"认证文件\",\n \"agent.openFileInEditor\": \"用编辑器打开\",\n \"agent.applyConfig\": \"应用配置\",\n \"agent.restoreConfig\": \"恢复原始配置\",\n \"agent.migrateSessions\": \"迁移会话\",\n \"agent.migrateConfirmTitle\": \"迁移 Codex 会话\",\n \"agent.migrateConfirmDesc\": \"将 Codex 本地会话元数据改写为当前 provider,覆盖历史会话、归档会话和 Codex 状态数据库,但不会删除任何会话。\",\n \"agent.migrateTarget\": \"目标 model_provider\",\n \"agent.migrateRunning\": \"正在迁移会话...\",\n \"agent.migrateConfirm\": \"确认迁移\",\n \"agent.migrateClose\": \"关闭\",\n \"agent.migrateResultSuccess\": \"迁移完成:{migrated}/{total} 个会话文件已更新\",\n \"agent.migrateResultSkippedLabel\": \"跳过文件\",\n \"agent.migrateResultFailedLabel\": \"失败文件\",\n \"agent.migrateSQLiteUpdated\": \"SQLite 数据库已更新:{count} 行\",\n \"agent.migrateSQLiteSkipped\": \"SQLite 数据库已跳过\",\n \"agent.openConsole\": \"访问官方控制台\",\n \"agent.codexPlaceholderSaved\": \"已保存,留空则使用已保存的 key\",\n \"agent.codexPlaceholderRequired\": \"必填:输入 API Key\",\n \"agent.codexPlaceholderWriteOnly\": \"仅写入 Codex 配置\",\n \"agent.diffPreviewApply\": \"应用配置预览\",\n \"agent.diffPreviewRestore\": \"恢复配置预览\",\n \"agent.diffConfirmApply\": \"确认应用\",\n \"agent.diffConfirmRestore\": \"确认恢复\",\n \"agent.diffActionCreate\": \"创建\",\n \"agent.diffActionDelete\": \"删除\",\n \"agent.diffActionModify\": \"修改\",\n \"agent.diffComputing\": \"计算变更中...\",\n \"agent.diffNoChanges\": \"无变更\",\n \"agent.diffExpandContext\": \"展开 {count} 行未变更内容\",\n \"agent.diffCollapseContext\": \"收起 {count} 行\",\n \"agent.diffCancel\": \"取消\",\n \"agent.codexMode\": \"模式\",\n \"agent.codexQuickMode\": \"快捷模式\",\n \"agent.codexPluginMode\": \"插件模式\",\n \"agent.codexQuickModeHint\": \"保留 OpenAI 对话历史。使用 OpenAI provider + 目标 API 端点。\",\n \"agent.codexPluginModeHint\": \"启用 Codex 插件功能。切换后之前的 OpenAI 对话可能不可见,切回快捷模式即可访问。\",\n \"agent.sessionMigrationWarning\": \"切换 Codex 模式后,已有会话可能不可见。会话不会被删除 — 切回之前的模式即可访问。\",\n \"agent.provider.localGateway\": \"CCX 本地网关\",\n \"agent.provider.deepseekDirect\": \"DeepSeek 直连\",\n \"agent.provider.mimoDirect\": \"MiMo 直连\",\n \"agent.provider.compshareDirect\": \"Compshare 直连\",\n \"agent.provider.runapiDirect\": \"RunAPI 直连\",\n \"agent.provider.unity2Direct\": \"Unity2.ai 直连\",\n \"agent.provider.kimiDirect\": \"Kimi 直连\",\n \"agent.provider.glmDirect\": \"GLM 直连\",\n \"agent.provider.sensenovaDirect\": \"SenseNova 直连\",\n \"agent.provider.minimaxDirect\": \"MiniMax 直连\",\n \"agent.provider.dashscopeDirect\": \"DashScope 直连\",\n \"agent.provider.opencodeZenDirect\": \"OpenCode Zen 直连\",\n \"agent.provider.opencodeGoDirect\": \"OpenCode Go 直连\",\n \"agent.provider.openaiDirect\": \"OpenAI 直连\",\n \"agent.provider.openrouterDirect\": \"OpenRouter 直连\",\n \"agent.provider.modelscopeDirect\": \"ModelScope 直连\",\n \"agent.provider.xfyunDirect\": \"讯飞星辰直连\",\n \"agent.provider.tencentLkeapDirect\": \"腾讯云 TokenHub 直连\",\n \"agent.provider.volcArkDirect\": \"火山方舟直连\",\n \"agent.provider.qianfanDirect\": \"百度千帆直连\",\n \"agent.hasOwnApiKey\": \"我有自己的 API Key\",\n \"agent.promo\": \"通过推广链接注册领取专属福利\",\n \"agent.planPayAsYouGo\": \"按量\",\n \"agent.planChina\": \"订阅套餐 - 中国\",\n \"agent.planSingapore\": \"订阅套餐 - 新加坡\",\n \"agent.planEurope\": \"订阅套餐 - 欧洲\",\n \"agent.planSubscription\": \"订阅套餐\",\n \"agent.billingModeMiMo\": \"MiMo 计费模式\",\n \"agent.billingModeDashScope\": \"DashScope 计费模式\",\n \"agent.placeholderSaved\": \"已保存,留空则使用已保存的 key\",\n \"agent.placeholderMimo\": \"必填:MiMo API Key(tp-xxx 或账号 key)\",\n \"agent.placeholderDashScope\": \"必填:DashScope API Key(sk-xxx 或 sk-sp-xxx)\",\n \"agent.placeholderRequired\": \"必填:输入 API Key\",\n \"agent.codexDiagnosticTitle\": \"Codex 排障诊断\",\n \"agent.codexTroubleshoot\": \"排障检测\",\n \"agent.codexTroubleshooting\": \"检测中...\",\n \"agent.codexDiagnosticHealthy\": \"当前 Codex 配置与 CCX 看起来是一致的。\",\n \"agent.codexDiagnosticMismatch\": \"config.toml 与 auth.json 可能不一致。\",\n \"agent.codexDiagnosticCheck\": \"检查项\",\n \"agent.codexDiagnosticAuthMode\": \"认证模式\",\n \"agent.codexDiagnosticConfigPath\": \"config.toml\",\n \"agent.codexDiagnosticAuthPath\": \"auth.json\",\n \"agent.codexDiagnosticSuggestedApply\": \"建议动作:重新应用 Codex -> CCX 配置。\",\n \"agent.codexDiagnosticSuggestedRestore\": \"如果问题仍在,先恢复原始配置,再重新应用。\",\n \"agent.codexDiagnosticStatusTitle\": \"Codex 排障摘要\",\n \"agent.codexDiagnosticStatusHint\": \"请前往 Agent 页检查 Codex 配置一致性。\",\n \"agent.codexDiagnosticCodeMissingApiKey\": \"config.toml 已指向本地 CCX,但 auth.json 缺少 OPENAI_API_KEY。\",\n \"agent.codexDiagnosticCodeProxyKeyMismatch\": \"Codex 当前保存的代理密钥与 CCX 当前代理密钥不一致。\",\n \"agent.codexDiagnosticCodeAuthModeMismatch\": \"config.toml 与 auth.json 要求的认证模式不一致。\",\n \"agent.codexDiagnosticCodePluginMissingBearer\": \"插件模式缺少 experimental_bearer_token。\",\n \"agent.codexDiagnosticCodeLegacyIncomplete\": \"旧式 ccx provider 配置不完整。\",\n \"agent.codexDiagnosticCodeConfigPolluted\": \"config.toml 可能被其他工具污染,已不符合 CCX 预期。\",\n \"agent.codexDiagnosticCodeAuthUnreadable\": \"auth.json 无法解析,可能已经损坏。\",\n \"agent.codexDiagnosticCodeModeMismatch\": \"当前 Codex 配置是{currentMode}模式,但你选择了{targetMode}模式。请重新应用配置。\",\n \"agent.responsesDiagnosticTitle\": \"Responses 渠道诊断\",\n \"agent.responsesDiagnosticHealthy\": \"Responses 渠道当前看起来可用。\",\n \"agent.responsesDiagnosticNoChannel\": \"CCX 当前没有配置任何 Responses 渠道。\",\n \"agent.responsesDiagnosticAllDisabled\": \"已存在 Responses 渠道,但当前没有任何活跃渠道。\",\n \"agent.responsesDiagnosticNoApiKeys\": \"活跃的 Responses 渠道缺少 API Key。\",\n \"agent.responsesDiagnosticProtocolMismatch\": \"已存在 Responses 渠道,但它们的协议路由看起来并不兼容 /v1/responses。\",\n \"agent.responsesDiagnosticCircuitOpen\": \"所有活跃的 Responses 渠道当前都处于熔断状态。\",\n \"agent.responsesDiagnosticSuggestedRefresh\": \"建议动作:刷新渠道状态和日志。\",\n \"agent.responsesDiagnosticSuggestedCreate\": \"建议动作:为当前 Codex provider 新增一个 Responses 渠道。\",\n \"agent.responsesDiagnosticSuggestedEnable\": \"建议动作:启用或恢复至少一个 Responses 渠道。\",\n \"agent.responsesDiagnosticSuggestedAddKey\": \"建议动作:给活跃的 Responses 渠道补充 API Key。\",\n \"agent.responsesDiagnosticSuggestedProtocolMismatch\": \"建议动作:检查 serviceType 和 baseUrl,确保该渠道最终落到 /responses 端点。\",\n \"agent.responsesDiagnosticSuggestedResume\": \"建议动作:查看日志并在恢复后重新启用相关渠道。\",\n \"agent.responsesLogsDiagnosticTitle\": \"最近失败请求\",\n \"agent.responsesLogsDiagnosticHealthy\": \"未发现最近的 Responses 失败请求。\",\n \"agent.responsesLogsDiagnosticAuth\": \"最近的 Responses 请求出现了认证错误(401/403)。\",\n \"agent.responsesLogsDiagnosticRateLimit\": \"最近的 Responses 请求触发了限流(429)。\",\n \"agent.responsesLogsDiagnosticUpstream5xx\": \"最近的 Responses 请求出现了上游 5xx 错误。\",\n \"agent.responsesLogsDiagnosticTimeout\": \"最近的 Responses 请求看起来发生了超时或卡住。\",\n \"agent.responsesLogsDiagnosticGeneric\": \"最近的 Responses 请求失败了,请查看渠道日志获取细节。\",\n \"agent.responsesLogsDiagnosticSuggestedRefresh\": \"建议动作:刷新最近的渠道日志。\",\n \"agent.responsesLogsDiagnosticSuggestedAuth\": \"建议动作:检查上游 API Key、账户额度和认证设置。\",\n \"agent.responsesLogsDiagnosticSuggestedRateLimit\": \"建议动作:降低请求频率,或等待上游限流窗口恢复。\",\n \"agent.responsesLogsDiagnosticSuggestedUpstream5xx\": \"建议动作:切换渠道、检查上游状态,或稍后再试。\",\n \"agent.responsesLogsDiagnosticSuggestedTimeout\": \"建议动作:检查上游延迟、超时配置和最近日志。\",\n \"agent.responsesLogsDiagnosticSuggestedGeneric\": \"建议动作:打开 Responses 渠道日志并检查最近失败项。\",\n \"agent.codexDiagnosticIntro\": \"按需排障检测:配置一致性 → Responses 渠道可用性 → 最近失败请求。\",\n \"agent.codexDiagnosticLayerConfig\": \"第 1 层 · Codex 配置\",\n \"agent.codexDiagnosticLayerChannels\": \"第 2 层 · Responses 渠道\",\n \"agent.codexDiagnosticLayerLogs\": \"第 3 层 · 最近失败请求\",\n \"env.groupAccess\": \"访问控制\",\n \"env.groupAccessDesc\": \"代理入口与管理入口的访问密钥。\",\n \"env.fieldProxyAccessKey\": \"代理访问密钥\",\n \"env.placeholderProxyAccessKey\": \"请输入强随机密钥\",\n \"env.fieldAdminAccessKey\": \"管理 API 独立密钥\",\n \"env.placeholderAdminAccessKey\": \"留空则回退到 PROXY_ACCESS_KEY\",\n \"env.descAdminAccessKey\": \"用于管理界面和 /api/* 端点。\",\n \"env.groupServer\": \"服务器配置\",\n \"env.groupServerDesc\": \"Desktop 会在启动时注入部分运行参数;这里仍完整覆盖 .env.example。\",\n \"env.fieldPort\": \"服务端口\",\n \"env.descPort\": \"启动时优先使用此端口,被占用时自动递增分配。\",\n \"env.fieldBindHost\": \"监听主机\",\n \"env.descBindHost\": \"留空等价于监听所有网卡(:PORT / 0.0.0.0);填写 127.0.0.1 则只允许本机访问。\",\n \"env.fieldEnableHttps\": \"启用本地 HTTPS\",\n \"env.descEnableHttps\": \"用于 Claude Desktop 等只接受 HTTPS 网关的客户端;开启后 WebURL 和健康检查都会切换为 https。\",\n \"env.fieldTlsAutoCert\": \"自动生成本地证书\",\n \"env.descTlsAutoCert\": \"未提供证书文件时生成临时 localhost 自签名证书;如客户端要求受信任证书,请改用证书文件。\",\n \"env.fieldTlsCertFile\": \"TLS 证书文件\",\n \"env.placeholderTlsCertFile\": \"例如 /Users/me/.config/ccx/localhost.pem\",\n \"env.descTlsCertFile\": \"PEM 格式证书路径;与 TLS_KEY_FILE 同时设置。\",\n \"env.fieldTlsKeyFile\": \"TLS 私钥文件\",\n \"env.placeholderTlsKeyFile\": \"例如 /Users/me/.config/ccx/localhost-key.pem\",\n \"env.descTlsKeyFile\": \"PEM 格式私钥路径;与 TLS_CERT_FILE 同时设置。\",\n \"env.fieldEnv\": \"运行环境\",\n \"env.descEnv\": \"production 为推荐值。\",\n \"env.groupWebUI\": \"Web UI 配置\",\n \"env.groupWebUIDesc\": \"控制管理界面是否启用以及默认语言。\",\n \"env.fieldEnableWebUI\": \"启用 Web UI\",\n \"env.descEnableWebUI\": \"Desktop 模式通常会强制启用。\",\n \"env.fieldAppUILanguage\": \"默认语言\",\n \"env.groupLogs\": \"日志配置\",\n \"env.groupLogsDesc\": \"控制请求/响应日志、SSE 调试和模型字段改写。\",\n \"env.fieldLogLevel\": \"日志级别\",\n \"env.fieldEnableRequestLogs\": \"启用请求日志\",\n \"env.fieldEnableResponseLogs\": \"启用响应日志\",\n \"env.descEnableResponseLogs\": \"响应日志可能增加敏感内容暴露风险。\",\n \"env.fieldQuietPollingLogs\": \"静默轮询日志\",\n \"env.fieldRawLogOutput\": \"原始日志输出\",\n \"env.fieldSseDebugLevel\": \"SSE 调试级别\",\n \"env.fieldRewriteResponseModel\": \"改写响应 model\",\n \"env.groupPerformance\": \"性能配置\",\n \"env.groupPerformanceDesc\": \"请求链路超时和请求体大小限制。\",\n \"env.fieldServerReadTimeout\": \"服务端读取超时(毫秒)\",\n \"env.fieldMaxRequestBodySize\": \"请求体最大大小(MB)\",\n \"env.groupCors\": \"CORS 配置\",\n \"env.groupCorsDesc\": \"跨域访问控制。\",\n \"env.fieldEnableCors\": \"启用 CORS\",\n \"env.fieldCorsOrigin\": \"允许的 Origin\",\n \"env.groupCircuitBreaker\": \"熔断指标配置\",\n \"env.groupCircuitBreakerDesc\": \"控制调度指标窗口与失败率阈值。\",\n \"env.fieldMetricsWindowSize\": \"滑动窗口大小\",\n \"env.fieldMetricsFailureThreshold\": \"失败率阈值\",\n \"env.runtimeCbTitle\": \"调校台\",\n \"env.runtimeCbDesc\": \"精调熔断与流控参数,修改立即生效\",\n \"env.runtimeCbWindowSize\": \"滑动窗口大小\",\n \"env.runtimeCbWindowSizeDesc\": \"计算失败率的最近请求数(3-100)\",\n \"env.runtimeCbFailureThreshold\": \"失败率阈值\",\n \"env.runtimeCbFailureThresholdDesc\": \"超过此值触发熔断(0.01-1.0)\",\n \"env.runtimeCbConsecutiveFailures\": \"连续失败阈值\",\n \"env.runtimeCbConsecutiveFailuresDesc\": \"连续可重试失败次数达此值触发熔断(1-100)\",\n \"env.runtimeCbRequestTimeout\": \"非流式请求超时\",\n \"env.runtimeCbResponseHeaderTimeout\": \"响应头等待超时\",\n \"env.runtimeCbStreamFirstContentTimeout\": \"首字等待超时\",\n \"env.runtimeCbStreamFirstContentTimeoutDesc\": \"HTTP 200 后等待首个有效内容的时间(0=禁用, 1000-300000ms)\",\n \"env.runtimeCbStreamInactivityTimeout\": \"首字后断流超时\",\n \"env.runtimeCbStreamInactivityTimeoutDesc\": \"首字后等待后续活动的时间(0=禁用, 1000-180000ms)\",\n \"env.runtimeCbStreamToolCallIdleTimeout\": \"工具调用空闲超时\",\n \"env.runtimeCbStreamToolCallIdleTimeoutDesc\": \"工具调用 pending 时等待有效输出的时间(1000-180000ms)\",\n \"env.runtimeCbPresetGentle\": \"温和\",\n \"env.runtimeCbPresetBalanced\": \"均衡\",\n \"env.runtimeCbPresetAggressive\": \"激进\",\n \"env.runtimeCbPresetCustom\": \"自定义\",\n \"env.runtimeCbSaved\": \"调校台设置已保存,修改立即生效\",\n \"env.runtimeCbSaveFailed\": \"保存失败:{error}\",\n \"env.runtimeCbLoadFailed\": \"加载失败:{error}\",\n \"env.runtimeCbNoBackend\": \"后端服务未运行,请先启动服务\",\n \"env.runtimeCbServiceStopped\": \"服务已停止,启动后可配置运行时参数\",\n \"env.groupMetricsPersistence\": \"指标持久化配置\",\n \"env.groupMetricsPersistenceDesc\": \"控制 SQLite 指标持久化与数据保留。\",\n \"env.fieldMetricsPersistenceEnabled\": \"启用指标持久化\",\n \"env.fieldMetricsRetentionDays\": \"指标保留天数\",\n \"logs.searchPlaceholder\": \"搜索日志...\",\n \"logs.autoScroll\": \"自动滚动到底部\",\n \"logs.copied\": \"已复制!\",\n \"logs.copyAll\": \"复制全部日志\",\n \"logs.clear\": \"清空日志控制台\",\n \"logs.noSearchResults\": \"未找到匹配的日志行\",\n \"logs.empty\": \"暂无日志输出,启动服务后即可查看\",\n \"diagnostic.binaryTitle\": \"二进制文件未找到\",\n \"diagnostic.binarySuggestionBuild\": \"确认 CCX 二进制已构建: cd backend-go && make build\",\n \"diagnostic.binarySuggestionCheckDataDir\": \"检查 Desktop 数据目录中是否存在 ccx-go / ccx-go.exe\",\n \"diagnostic.binarySuggestionDownload\": \"首次使用需先构建后端,或从 Release 页面下载预编译版本\",\n \"diagnostic.portTitle\": \"端口冲突\",\n \"diagnostic.portSuggestionInstance\": \"检查是否有其他 CCX 实例已在运行\",\n \"diagnostic.portSuggestionEnv\": \"修改 .env 中 PORT 字段使用其他端口\",\n \"diagnostic.portSuggestionInspect\": \"使用 lsof -i :3688 (macOS/Linux) 或 netstat -ano | findstr :3688 (Windows) 检查端口占用\",\n \"diagnostic.healthTitle\": \"健康检查超时\",\n \"diagnostic.healthSuggestionLogs\": \"查看日志面板中是否有启动错误信息\",\n \"diagnostic.healthSuggestionEnv\": \"检查 .env 配置是否有语法错误\",\n \"diagnostic.healthSuggestionChannels\": \"确认上游渠道配置正确,首次启动可能需要较长时间\",\n \"diagnostic.healthSuggestionRestart\": \"尝试手动重启服务\",\n \"diagnostic.permissionTitle\": \"权限不足\",\n \"diagnostic.permissionSuggestionDataDir\": \"检查数据目录是否有写入权限\",\n \"diagnostic.permissionSuggestionExecutable\": \"macOS/Linux: 确认二进制文件有执行权限 (chmod +x)\",\n \"diagnostic.permissionSuggestionWindows\": \"Windows: 尝试以管理员身份运行\",\n \"diagnostic.genericTitle\": \"启动失败\",\n \"diagnostic.genericSuggestionLogs\": \"查看下方日志面板获取详细错误信息\",\n \"diagnostic.genericSuggestionRestart\": \"尝试重启服务\",\n \"setup.errorEmptyKey\": \"PROXY_ACCESS_KEY 不能为空\",\n \"env.saveSuccessHint\": \".env 已保存,重启服务后生效\",\n \"env.openedInEditor\": \"已在编辑器中打开 .env 文件\",\n \"sidebar.versionHintStore\": \"Microsoft Store 版本由 Store 自动更新\",\n \"sidebar.versionHintTray\": \"通过托盘菜单检查更新\",\n \"sidebar.versionClickCheck\": \"点击检查更新\",\n \"sidebar.updateAvailable\": \"新版 {version}\",\n \"sidebar.updateAvailableHint\": \"点击查看发布说明\",\n \"sidebar.updateInstallHint\": \"点击安装更新\",\n \"sidebar.updateOpenReleaseHint\": \"点击查看发布说明\",\n \"sidebar.theme\": \"主题\",\n \"sidebar.themeAuto\": \"跟随系统\",\n \"sidebar.themeLight\": \"亮色\",\n \"sidebar.themeDark\": \"暗色\",\n \"app.tabs.messages\": \"渠道管理\",\n \"app.tabs.conversations\": \"驾驶舱\",\n \"console.nativeChannelsEyebrow\": \"原生渠道编排\",\n \"console.nativeChannelsTitle\": \"WebUI 对齐协议工作区\",\n \"console.quickAdd\": \"快速添加\",\n \"console.quickAddEyebrow\": \"渠道预设\",\n \"console.quickAddTitle\": \"快速添加渠道\",\n \"console.actions.deleting\": \"正在删除...\",\n \"common.refreshing\": \"刷新中\",\n \"common.copy\": \"复制\",\n \"common.copied\": \"已复制\",\n \"common.delete\": \"删除\",\n \"common.add\": \"添加\",\n \"orchestration.searchPlaceholder\": \"搜索渠道...\",\n \"orchestration.noMatchingChannels\": \"没有匹配的渠道\",\n \"app.actions.refresh\": \"刷新\",\n \"app.actions.ping\": \"测试延迟\",\n \"app.actions.addChannel\": \"添加渠道\",\n \"orchestration.noActiveChannels\": \"暂无渠道,点击上方按钮添加\",\n \"orchestration.multiChannel\": \"多渠道模式\",\n \"orchestration.singleChannel\": \"单渠道模式\",\n \"orchestration.failoverSequence\": \"故障转移序列\",\n \"orchestration.standbyPool\": \"备用资源池\",\n \"orchestration.enable\": \"启用\",\n \"channelCard.suspended\": \"暂停\",\n \"channelCard.actions\": \"操作\",\n \"orchestration.moveToPool\": \"移至备用池\",\n \"orchestration.cache\": \"缓存\",\n \"orchestration.cacheWriteHigh\": \"缓存写偏高\",\n \"orchestration.cacheWriteHighHint\": \"15 分钟内缓存写入占比偏高,可能存在缓存命中配置不合理的问题。\",\n \"chart.globalStats\": \"用量统计\",\n \"status.normal\": \"正常\",\n \"status.paused\": \"暂停\",\n \"status.tripped\": \"熔断\",\n \"status.disabled\": \"禁用\",\n \"channelCard.apiKeyManagement\": \"API密钥管理\",\n \"channelCard.configuredKeys\": \"已配置的密钥\",\n \"orchestration.edit\": \"编辑\",\n \"capability.startTest\": \"开始测试\",\n \"orchestration.logs\": \"日志\",\n \"orchestration.copyConfig\": \"复制配置\",\n \"orchestration.openWebsite\": \"打开官网\",\n \"orchestration.pause\": \"暂停\",\n \"orchestration.resume\": \"恢复\",\n \"orchestration.promotion\": \"抢优先级 (5分钟)\",\n \"orchestration.delete\": \"删除\",\n \"orchestration.resumeReset\": \"恢复 (重置指标)\",\n \"orchestration.moveTop\": \"置顶\",\n \"orchestration.moveBottom\": \"置底\",\n \"orchestration.keepOne\": \"(至少保留一个)\",\n \"orchestration.deleteActiveGuard\": \"无法删除:故障转移序列中至少需要保留一个活跃渠道\",\n \"channelCard.disabledKeys\": \"已拉黑密钥\",\n \"channelCard.blacklistReason.authentication_error\": \"认证失败\",\n \"channelCard.blacklistReason.permission_error\": \"权限不足\",\n \"channelCard.blacklistReason.insufficient_balance\": \"余额不足\",\n \"channelCard.blacklistReason.insufficient_quota\": \"额度已耗尽\",\n \"channelCard.blacklistReason.unavailable\": \"不可用\",\n \"channelCard.blacklistReason.rate_limited\": \"频率限制\",\n \"channelCard.blacklistReason.invalid\": \"密钥无效\",\n \"channelCard.blacklistReason.unknown\": \"未知原因\",\n \"channelEditor.title.create\": \"添加渠道\",\n \"channelEditor.title.edit\": \"编辑渠道\",\n \"channelEditor.managed.providerLabel\": \"渠道提供商\",\n \"channelEditor.managed.officialChannel\": \"{provider} 官方渠道\",\n \"channelEditor.managed.officialBadge\": \"官方托管\",\n \"channelEditor.managed.editSubtitle\": \"{provider} 官方渠道 · 管理账号凭证\",\n \"channelEditor.managed.providerChannel\": \"{provider} 托管渠道\",\n \"channelEditor.managed.managedBadge\": \"自动托管\",\n \"channelEditor.managed.providerEditSubtitle\": \"{provider} 托管渠道 · 管理账号凭证\",\n \"channelEditor.managed.customEditSubtitle\": \"自动托管渠道 · 管理名称与账号凭证\",\n \"channelEditor.nav.basic\": \"基础信息\",\n \"channelEditor.basic.name.label\": \"名称\",\n \"channelEditor.basic.name.required\": \"渠道名称必填\",\n \"channelEditor.basic.serviceType.label\": \"上游类型\",\n \"channelEditor.basic.serviceType.placeholder\": \"选择上游类型\",\n \"channelEditor.basic.serviceType.required\": \"请选择上游类型\",\n \"channelEditor.basic.description.label\": \"描述\",\n \"channelEditor.nav.connection\": \"连接\",\n \"channelEditor.basic.baseUrl.label\": \"Base URL\",\n \"channelEditor.basic.baseUrl.required\": \"至少需要一个 Base URL\",\n \"channelEditor.basic.additionalUrls.label\": \"额外 URL(每行一个)\",\n \"channelEditor.transport.proxyUrl.label\": \"代理通道 URL\",\n \"channelEditor.transport.routePrefix.label\": \"接口路由前缀\",\n \"channelEditor.transport.skipTls.label\": \"跳过 TLS 验证\",\n \"channelEditor.nav.auth\": \"认证管理\",\n \"channelEditor.auth.keys.label\": \"API Keys\",\n \"channelEditor.auth.disabledKeys.label\": \"已禁用密钥\",\n \"channelEditor.auth.restoreKey\": \"恢复\",\n \"channelEditor.auth.copyKey\": \"复制密钥\",\n \"channelEditor.auth.deleteKey\": \"删除密钥\",\n \"channelEditor.auth.addKey\": \"添加 Key\",\n \"channelEditor.auth.historicalKeys\": \"历史密钥记录\",\n \"channelEditor.advanced.authHeader.label\": \"认证头\",\n \"channelEditor.advanced.authHeader.hint\": \"覆盖上游 API Key 发送方式;自动模式保持现有兼容逻辑\",\n \"channelEditor.advanced.authHeader.auto\": \"自动\",\n \"addChannel.testCapability\": \"测试能力\",\n \"addChannel.oneClickSetup\": \"一键配置\",\n \"addChannel.commonFilters\": \"常用过滤器\",\n \"addChannel.autoConvertModelNames\": \"自动转换模型名称\",\n \"addChannel.channelName\": \"渠道名称\",\n \"addChannel.apiKeys\": \"API Keys\",\n \"addChannel.detectedKeys\": \"已检测到 {count} 个 API Key\",\n \"addChannel.enterApiKey\": \"请输入至少一个 API Key\",\n \"copilotOAuth.quickAddKeyHint\": \"无需粘贴 API Key,创建后登录 GitHub Copilot 授权\",\n \"addChannel.newChannelPlacementLabel\": \"添加到队列末尾\",\n \"addChannel.expectedRequest\": \"预期请求\",\n \"addChannel.apiKeyLoadBalance\": \"可添加多个密钥作为故障转移备用\",\n \"addChannel.checking\": \"检测中...\",\n \"addChannel.modelsCount\": \"models {statusCode} ({count} 个)\",\n \"addChannel.supportedModelsLabel\": \"支持的模型 (可选)\",\n \"addChannel.supportedModelsPlaceholder\": \"输入规则后按回车添加,如 gpt-4*、*image*、!*image*\",\n \"addChannel.supportedModelsHint\": \"匹配客户端请求的原始模型名(模型重定向发生在渠道选中之后)。留空表示支持所有模型。支持精确匹配、prefix*、*suffix、*contains* 以及 ! 排除规则。\",\n \"addChannel.supportedModelsInvalidPattern\": \"已忽略非法规则。仅支持精确匹配、prefix*、*suffix、*contains* 和 ! 排除规则。\",\n \"addChannel.contextCapabilityTitle\": \"上下文能力\",\n \"addChannel.defaultContextWindowLabel\": \"默认上下文窗口 tokens\",\n \"addChannel.defaultContextWindowHint\": \"当实际模型不在内置库或 JSON 覆盖中时使用;留空则走未知模型策略。\",\n \"addChannel.defaultMaxOutputLabel\": \"默认最大输出 tokens\",\n \"addChannel.defaultMaxOutputHint\": \"仅在请求显式设置输出上限时参与过滤。\",\n \"addChannel.modelCapabilitiesJsonLabel\": \"实际模型能力 JSON\",\n \"addChannel.modelCapabilitiesJsonHint\": \"key 使用模型重定向后的实际模型名,支持通配符;空对象表示不覆盖内置能力。\",\n \"addChannel.modelCapabilitiesJsonInvalid\": \"请输入合法的 JSON 对象,值只能包含 contextWindowTokens、maxOutputTokens、thinkingMode、reasoningEfforts。\",\n \"addChannel.allowUnknownContextLabel\": \"允许未知模型承载大上下文\",\n \"addChannel.allowUnknownContextHint\": \"默认未知能力模型只允许 200K 以内请求;启用后大上下文也可候选。\",\n \"addChannel.visionFallbackLabel\": \"视觉回退模型\",\n \"addChannel.visionFallbackPlaceholder\": \"含图请求时使用的替代模型\",\n \"addChannel.visionFallbackHint\": \"当请求包含图片且目标模型不支持 vision 时,使用此模型替代。留空则 failover 到下一个渠道。\",\n \"addChannel.visionFallbackReasoningLabel\": \"回退思考强度\",\n \"addChannel.visionFallbackReasoningHint\": \"含图请求改用视觉回退模型时使用的思考强度。\",\n \"channelEditor.auth.apiKeyRequired\": \"至少需要一个 API Key\",\n \"channelEditor.mapping.reasoningEffort.label\": \"推理强度\",\n \"channelEditor.compat.noVisionModels\": \"无视觉模型(每行一个)\",\n \"channelEditor.compat.visionFallback.label\": \"视觉回退模型\",\n \"channelEditor.mapping.fetchModels\": \"获取模型列表\",\n \"channelEditor.mapping.fetchingModels\": \"拉取中...\",\n \"channelEditor.mapping.modelFetchNeedsConfig\": \"需要 Base URL 和 API Key 才能获取模型列表\",\n \"channelEditor.compat.visionEnabled\": \"视觉已启用\",\n \"channelEditor.compat.visionDisabled\": \"视觉已禁用\",\n \"channelEditor.mapping.models\": \"模型\",\n \"channelEditor.mapping.configured.label\": \"已配置\",\n \"channelEditor.mapping.supportedModels\": \"支持的模型(每行一个,留空=全部)\",\n \"addChannel.modelHintTip\": \"点击目标模型输入框会自动获取上游支持的模型列表,每个 API Key 的检测状态会显示在密钥列表中\",\n \"addChannel.modelMappingHintChat\": \"配置模型名称映射,将请求中的模型名重定向到目标模型。例如:将 \\\"gpt-4o\\\" 重定向到 \\\"gpt-5.4\\\"\",\n \"addChannel.modelMappingHintGemini\": \"配置模型名称映射,将请求中的模型名重定向到目标模型。例如:将 \\\"gemini-pro\\\" 重定向到 \\\"gemini-3.5-flash\\\"\",\n \"addChannel.modelMappingHintResponses\": \"配置模型名称映射,将请求中的模型名重定向到目标模型。例如:将 \\\"opus\\\" 重定向到 \\\"gpt-5.4\\\"\",\n \"addChannel.modelMappingHintVectors\": \"配置 Embeddings 模型名称映射,将请求中的向量模型名重定向到目标模型。例如:将 \\\"text-embedding-3-small\\\" 重定向到 \\\"jina-embeddings-v2-base-zh\\\"\",\n \"addChannel.modelMappingHintMessages\": \"配置模型名称映射,将请求中的模型名重定向到目标模型。例如:将 \\\"opus\\\" 重定向到 \\\"claude-sonnet-4-5-20250929\\\"\",\n \"addChannel.targetModelPlaceholderChat\": \"例如:gpt-5.4\",\n \"addChannel.targetModelPlaceholderResponses\": \"例如:gpt-5.4\",\n \"addChannel.targetModelPlaceholderGemini\": \"例如:gemini-3.5-flash\",\n \"addChannel.targetModelPlaceholderVectors\": \"例如:text-embedding-3-small\",\n \"addChannel.targetModelPlaceholderMessages\": \"例如:claude-opus-4-6\",\n \"addChannel.sourceModelNameTooLong\": \"自定义模型名过长(最多 50 字符)\",\n \"addChannel.sourceModelNoSpaces\": \"模型名不能包含空格\",\n \"addChannel.sourceModelInvalidChars\": \"模型名只能包含字母、数字、连字符、下划线、点、斜杠、冒号、@ 和 +\",\n \"addChannel.fillBaseUrlAndApiKey\": \"请先填写 Base URL 和至少一个 API Key\",\n \"addChannel.allApiKeysModelsFailed\": \"所有 API Key 都无法获取模型列表,请检查 API 密钥列表中的错误信息\",\n \"channelEditor.compat.title\": \"协议规范化\",\n \"channelEditor.compat.diagnose\": \"诊断\",\n \"channelEditor.compat.diagnoseApplied\": \"已应用 {count} 项推荐配置\",\n \"channelEditor.compat.diagnoseNoChange\": \"当前配置已是最优,无需调整\",\n \"channelEditor.compat.diagnoseFailed\": \"诊断失败,请检查渠道配置\",\n \"channelEditor.compat.diagnoseAppliedShort\": \"已应用 {count} 项\",\n \"channelEditor.compat.diagnoseNoChangeShort\": \"已最优\",\n \"channelEditor.compat.diagnoseFailedShort\": \"失败\",\n \"channelEditor.compat.reasoningStyle.label\": \"Reasoning 参数样式\",\n \"channelEditor.compat.textVerbosity.label\": \"输出冗长度\",\n \"channelEditor.compat.flags\": \"高级选项\",\n \"channelEditor.compat.noVision.label\": \"跳过含图请求\",\n \"channelEditor.compat.fastMode.label\": \"快速模式\",\n \"channelEditor.transport.lowQuality.label\": \"低质量渠道\",\n \"channelEditor.compat.injectDummySignature.label\": \"注入 Gemini 思考标记\",\n \"channelEditor.compat.stripThoughtSignature.label\": \"剥离 Gemini 思考标记\",\n \"channelEditor.compat.normalizeSystem.label\": \"规范化 System 角色域\",\n \"channelEditor.compat.normalizeUserId.label\": \"规范化用户 ID\",\n \"channelEditor.compat.stripBillingHeader.label\": \"剔除 CCH 计费尾缀\",\n \"channelEditor.runtime.autoBlacklist.label\": \"自动熔断/黑名单余额异常 Key\",\n \"channelEditor.compat.codexCompat.label\": \"Codex 工具兼容模式\",\n \"channelEditor.compat.stripCodexTools.label\": \"移除 Codex 客户端工具\",\n \"channelEditor.nav.custom\": \"自定义参数\",\n \"channelEditor.transport.requestTimeout.label\": \"请求超时 (s)\",\n \"channelEditor.transport.requestTimeout.invalid\": \"请求超时必须是 1-300 秒之间的整数\",\n \"channelEditor.transport.responseHeaderTimeout.label\": \"响应头等待超时 (s)\",\n \"channelEditor.transport.responseHeaderTimeout.invalid\": \"响应头等待超时必须是 1-300 秒之间的整数\",\n \"channelEditor.streamTimeout.title\": \"流式断流超时控制\",\n \"channelEditor.streamTimeout.firstContent.overrideLabel\": \"自定义首字等待超时\",\n \"channelEditor.streamTimeout.firstContent.label\": \"首字等待\",\n \"channelEditor.streamTimeout.inactivity.overrideLabel\": \"自定义断流超时\",\n \"channelEditor.streamTimeout.inactivity.label\": \"首字后断流\",\n \"channelEditor.streamTimeout.toolCallIdle.overrideLabel\": \"自定义工具调用空闲超时\",\n \"channelEditor.streamTimeout.toolCallIdle.label\": \"工具调用空闲\",\n \"channelEditor.streamTimeout.overrideHint\": \"自定义值将覆盖此渠道的全局流式超时设置\",\n \"channelEditor.streamTimeout.inheritHint\": \"禁用表示此渠道继承全局流式超时设置\",\n \"channelEditor.actions.cancel\": \"取消\",\n \"channelEditor.actions.create\": \"创建\",\n \"channelEditor.actions.save\": \"保存\",\n \"channelEditor.error.channelNotFound\": \"未找到渠道配置\",\n \"channelEditor.basic.website.label\": \"网站\",\n \"channelEditor.basic.baseUrl.placeholder\": \"每行一个,第一行为主地址,其余作为故障转移\\nhttps://api.example.com\\nhttps://backup.example.com\",\n \"channelEditor.mapping.redirect.title\": \"模型重定向\",\n \"channelEditor.nav.advanced\": \"高级选项\",\n \"channelEditor.nav.outline\": \"配置大纲\",\n \"channelEditor.runtime.title\": \"运行期策略\",\n \"channelEditor.transport.title\": \"代理路由网络\",\n \"channelEditor.rateLimit.title\": \"上游主动限速流量阀\",\n \"channelEditor.compat.fastMode.hint\": \"Codex /fast mode:在支持的模型上提升生成速度,适合更快编码迭代;会以更高倍率消耗额度或使用限制\",\n \"channelEditor.compat.textVerbosity.style\": \"文本详细程度样式\",\n \"channelEditor.compat.vision.title\": \"视觉控制\",\n \"channelEditor.compat.selectDefault\": \"默认\",\n \"channelEditor.compat.autoBlacklistBalance.label\": \"自动熔断/黑名单余额异常 Key\",\n \"channelEditor.compat.autoBlacklistBalance.hint\": \"自动禁用余额异常的 Key\",\n \"channelEditor.runtime.autoLearnRateLimits.label\": \"自动学习上游限速\",\n \"channelEditor.runtime.autoLearnRateLimits.hint\": \"解析 Retry-After / x-ratelimit-* 响应头动态调整 cooldown\",\n \"channelEditor.rateLimit.rpm.label\": \"每分钟最大请求量 (RPM)\",\n \"channelEditor.rateLimit.rpm.placeholder\": \"不设限制\",\n \"channelEditor.rateLimit.window.label\": \"检测窗口滑动时间 (s)\",\n \"channelEditor.rateLimit.window.placeholder\": \"60 秒\",\n \"channelEditor.rateLimit.maxConcurrent.label\": \"全双工最大并发数限制\",\n \"channelEditor.rateLimit.maxConcurrent.placeholder\": \"不设限制\",\n \"channelEditor.streamTimeout.preset.gentle\": \"宽松\",\n \"channelEditor.streamTimeout.preset.balanced\": \"平衡\",\n \"channelEditor.streamTimeout.preset.aggressive\": \"激进\",\n \"channelEditor.streamTimeout.firstContent.hint\": \"未响应则自动断开\",\n \"channelEditor.streamTimeout.inactivity.hint\": \"生成中途卡顿超时\",\n \"channelEditor.streamTimeout.toolCallIdle.hint\": \"FunctionCall 延迟超时\",\n \"channelEditor.streamTimeout.timeoutThreshold\": \"超时阈值\",\n \"channelEditor.mapping.supportedModels.label\": \"限定可支持模型范围(白名单模式,留空表示不限制)\",\n \"channelEditor.mapping.source.duplicate\": \"该源模型已存在映射\",\n \"channelEditor.basic.info.title\": \"基础信息\",\n \"channelEditor.connection.title\": \"连接终点\",\n \"channelEditor.basic.multiLineFailover\": \"多行实现故障轮换\",\n \"channelEditor.basic.expectedEndpoint\": \"预期终点:\",\n \"channelEditor.basic.description.placeholder\": \"可选填,用于备注此节点供应商...\",\n \"channelEditor.mapping.hint\": \"拦截调用请求中的 Source 别名并定向投递至上游 Target 真实模型\",\n \"channelEditor.compat.visionFallback.hint\": \"已通过模型重定向行的视觉开关标记禁用视觉模型;这些模型遇到图像输入时会自动切换到此模型处理\",\n \"channelEditor.mapping.addRedirect\": \"添加新重定向\",\n \"channelEditor.compat.generationParams\": \"生成参数\",\n \"channelEditor.compat.textVerbosity.placeholder\": \"默认\",\n \"channelEditor.mapping.modelScope\": \"模型范围\",\n \"channelEditor.compat.noVision.hint\": \"启用后,包含图片的请求将跳过此渠道并 failover 到下一个渠道\",\n \"channelEditor.compat.historicalImageLimit.label\": \"历史图片轮次限制\",\n \"channelEditor.compat.historicalImageLimit.hint\": \"0 表示不限制;填写 2-10 时,超过限制的历史图片会被替换为占位符\",\n \"channelEditor.compat.compactModel.label\": \"Compact 专用模型\",\n \"channelEditor.compat.compactModel.hint\": \"本地 compact(压缩摘要)时使用的上游模型名,不经过模型映射。对非 Responses 协议渠道生效;对 Responses 渠道,在原生 compact 失败时回退使用。\",\n \"channelEditor.compat.stripThoughtSignature.hint\": \"移除 functionCall 中的 thought_signature 字段\",\n \"channelEditor.compat.injectDummySignature.hint\": \"为第三方 API 注入假签名\",\n \"channelEditor.compat.reasoningStyle.hint\": \"选择 OpenAI 风格请求使用 reasoning.effort 还是 reasoning_effort\",\n \"channelEditor.compat.codexCompat.hint\": \"启用 Codex CLI 兼容模式\",\n \"channelEditor.compat.normalizeSystem.hint\": \"将 messages 数组里的 system 消息抽取回顶层 system 字段。国产模型官方直连(DeepSeek、Kimi、GLM、MiniMax 等)多仅支持 user/assistant,通常需要开启;标准 Anthropic 端点或完整中转站一般无需开启。建议先点「诊断」自动检测。\",\n \"channelEditor.compat.normalizeUserId.hint\": \"扁平化 metadata 中的 user ID\",\n \"channelEditor.compat.stripBillingHeader.hint\": \"移除 CCH 计费后缀(非官方域名默认开启)\",\n \"channelEditor.transport.lowQuality.hint\": \"标记此渠道为低质量,作为最后手段\",\n \"channelEditor.runtime.autoBlacklist.hint\": \"自动禁用余额异常的 Key\",\n \"channelEditor.transport.skipTls.hint\": \"禁用上游 TLS 证书验证\",\n \"channelEditor.transport.proxyUrl.hint\": \"代理上游请求的通道\",\n \"channelEditor.transport.routePrefix.hint\": \"接口路由前缀\",\n \"channelEditor.transport.requestTimeout.hint\": \"非流式上游请求总超时;留空继承全局\",\n \"channelEditor.transport.responseHeaderTimeout.hint\": \"连接后等待上游 HTTP 响应头;慢启动渠道可单独放宽\",\n \"channelEditor.rateLimit.section.label\": \"主动限速\",\n \"channelEditor.rateLimit.section.hint\": \"在请求发往上游前主动限流,避免触发上游 429。\",\n \"channelEditor.rateLimit.rpm.hint\": \"每分钟请求数限制\",\n \"channelEditor.rateLimit.burst.label\": \"突发容量\",\n \"channelEditor.rateLimit.burst.hint\": \"令牌桶容量;留空时自动取每分钟请求数。\",\n \"channelEditor.rateLimit.maxConcurrent.hint\": \"全双工并发流限制\",\n \"addChannel.duplicateKey\": \"重复密钥\",\n \"addChannel.addNewApiKeyPlaceholder\": \"输入完整的API密钥\",\n \"addChannel.customHeadersLabel\": \"自定义请求头 (可选)\",\n \"addChannel.customHeadersHint\": \"添加或覆盖发送到上游的 HTTP 请求头\",\n \"addChannel.streamFirstContentTimeoutLabel\": \"首字等待超时\",\n \"addChannel.streamInactivityTimeoutLabel\": \"首字后断流超时\",\n \"addChannel.streamToolCallIdleTimeoutLabel\": \"工具调用空闲超时\",\n \"addChannel.streamTimeoutStrategyLabel\": \"流式超时策略\",\n \"addChannel.streamTimeoutStrategyInherit\": \"继承全局\",\n \"addChannel.streamTimeoutOverrideHint\": \"自定义值会覆盖本渠道的全局流式超时。\",\n \"addChannel.streamTimeoutInheritHint\": \"关闭表示本渠道继承全局流式超时。\",\n \"addChannel.headerNameLabel\": \"Header 名称\",\n \"addChannel.headerValueLabel\": \"请求头值\",\n \"addChannel.descriptionLabel\": \"描述 (可选)\",\n \"addChannel.descriptionHint\": \"可选的渠道描述...\",\n \"addChannel.skipTlsLabel\": \"跳过 TLS 证书验证\",\n \"addChannel.skipTlsHint\": \"仅在自签名或域名不匹配时临时启用,生产环境请关闭\",\n \"addChannel.lowQualityLabel\": \"低质量渠道\",\n \"addChannel.lowQualityHint\": \"启用后强制本地估算 token 数量,偏差超过 5% 时使用本地值\",\n \"addChannel.quickMode\": \"快速粘贴\",\n \"addChannel.quickHint\": \"粘贴 Base URL、API Key 或完整配置片段,自动识别并填入表单。\",\n \"addChannel.applyDetected\": \"应用识别结果\",\n \"addChannel.serviceTypeManual\": \"手动选择\",\n \"addChannel.serviceTypeDetected\": \"已自动识别\",\n \"addChannel.serviceTypeDefault\": \"使用默认类型\",\n \"addChannel.serviceTypeRecommendedSuffix\": \" · 推荐\",\n \"addChannel.noneDetected\": \"未识别\",\n \"addChannel.fullSubtitle\": \"配置渠道、密钥、模型与高级协议选项\",\n \"addChannel.editSubtitle\": \"修改渠道配置信息\",\n \"cockpit.searchPlaceholder\": \"搜索会话...\",\n \"cockpit.allKinds\": \"所有类型\",\n \"cockpit.refresh\": \"刷新\",\n \"cockpit.total\": \"共 {count} 个会话\",\n \"cockpit.empty\": \"暂无活跃会话。请求经过网关后,会话会出现在驾驶舱雷达上。\",\n \"cockpit.noMatches\": \"没有匹配的会话\",\n \"cockpit.overrideTitle\": \"会话渠道覆盖\",\n \"cockpit.overrideHint\": \"选择该会话使用的渠道序列(按优先级排列):\",\n \"cockpit.overrideSequence\": \"覆盖序列\",\n \"cockpit.overrideRequired\": \"请至少选择一个渠道\",\n \"cockpit.saveOverride\": \"保存覆盖\",\n \"cockpit.copyRawUserId\": \"复制对话 ID\",\n \"cockpit.rawUserIdCopied\": \"对话 ID 已复制\",\n \"cockpit.rawUserIdCopyFailed\": \"复制失败\",\n \"cockpit.tooltip.statusStreaming\": \"状态:正在流式响应\",\n \"cockpit.tooltip.statusActive\": \"状态:处理中\",\n \"cockpit.tooltip.statusIdle\": \"状态:空闲\",\n \"cockpit.tooltip.statusUnknown\": \"状态:未知\",\n \"cockpit.tooltip.kind\": \"请求类型:{kind}\",\n \"cockpit.tooltip.requests\": \"请求轮次:{count}\",\n \"cockpit.tooltip.duration\": \"持续时间:{duration}\",\n \"cockpit.tooltip.subagents\": \"子代理数量:{count}\",\n \"cockpit.tooltip.parentConversation\": \"跳转到主对话:{id}\",\n \"cockpit.tooltip.parentThread\": \"父线程:{id}\",\n \"cockpit.tooltip.childConversation\": \"跳转到子代理:{id}\",\n \"cockpit.tooltip.hiddenChannels\": \"展开查看剩余 {count} 个渠道\",\n \"cockpit.tooltip.restoreDefault\": \"恢复默认渠道顺序\",\n \"cockpit.tooltip.moreSubagents\": \"展开查看剩余 {count} 个子代理\",\n \"cockpit.tooltip.clearSubagentOverride\": \"清除子代理渠道指定\",\n \"cockpit.tooltip.quickCurrentChannel\": \"点击固定当前渠道\",\n \"cockpit.tooltip.quickNextOverride\": \"下一次覆盖目标\",\n \"cockpit.tooltip.quickSetNext\": \"固定为此对话优先渠道\",\n \"cockpit.tooltip.quickResumeAndSetNext\": \"恢复并固定此渠道\",\n \"cockpit.tooltip.quickPinned\": \"已固定为此对话优先渠道,再次点击取消固定\",\n \"cockpit.tooltip.currentChannel\": \"当前正在使用的渠道\",\n \"cockpit.tooltip.nextChannel\": \"下一次将使用的覆盖目标\",\n \"cockpit.tooltip.nextChannelTripped\": \"下一目标已熔断\",\n \"cockpit.tooltip.pausedChannel\": \"渠道已暂停,不参与调度\",\n \"cockpit.tooltip.circuitOpen\": \"熔断已打开,暂时跳过该渠道\",\n \"cockpit.tooltip.channelAlreadyFirst\": \"{name} 已在渠道顺序最前\",\n \"cockpit.tooltip.moveChannelToTop\": \"将 {name} 移到渠道顺序最前\",\n \"cockpit.tooltip.demoteChannel\": \"将 {name} 移到渠道顺序末尾\",\n \"cockpit.tooltip.demoteChannelDisabled\": \"已经是最后一个渠道\",\n \"cockpit.tooltip.columnCount\": \"{column}:{count} 个会话\",\n \"cockpit.overrideActivePerpetual\": \"正在使用自定义渠道顺序(手动恢复前不会自动过期)\",\n \"cockpit.overrideActive\": \"正在使用自定义渠道顺序,{time} 后自动恢复默认调度\",\n \"cockpit.restoreDefault\": \"恢复默认顺序\",\n \"cockpit.overrideDuration\": \"空闲自动恢复\",\n \"cockpit.noChannelsForKind\": \"该类型暂无可用渠道\",\n \"cockpit.board.streaming\": \"流式中\",\n \"cockpit.board.streamingHint\": \"正在流式输出的会话\",\n \"cockpit.board.working\": \"工作中\",\n \"cockpit.board.workingHint\": \"存在活跃工作的主对话\",\n \"cockpit.board.subagents\": \"子代理\",\n \"cockpit.board.subagentsHint\": \"包含子代理的会话\",\n \"cockpit.board.active\": \"活跃\",\n \"cockpit.board.activeHint\": \"仍在活跃但未流式输出的会话\",\n \"cockpit.board.idle\": \"空闲\",\n \"cockpit.board.idleHint\": \"当前空闲的会话\",\n \"cockpit.relation.parent\": \"主对话\",\n \"cockpit.relation.parentThread\": \"父线程 {id}\",\n \"cockpit.relation.children\": \"子代理 {count}\",\n \"cockpit.active\": \"活跃: {count}\",\n \"cockpit.override\": \"覆盖: {count}\",\n \"cockpit.mainConversation\": \"主对话\",\n \"cockpit.recap\": \"Recap\",\n \"cockpit.detail.requests\": \"请求轮次\",\n \"cockpit.detail.duration\": \"持续时间\",\n \"cockpit.mainRouting\": \"主对话渠道\",\n \"cockpit.subagentRouting\": \"子代理渠道\",\n \"cockpit.durationDefault\": \"30 分钟(默认)\",\n \"cockpit.duration15min\": \"15 分钟\",\n \"cockpit.duration1hour\": \"1 小时\",\n \"cockpit.duration2hours\": \"2 小时\",\n \"cockpit.duration4hours\": \"4 小时\",\n \"cockpit.duration8hours\": \"8 小时\",\n \"cockpit.duration12hours\": \"12 小时\",\n \"cockpit.duration24hours\": \"24 小时\",\n \"cockpit.durationNever\": \"永不恢复\",\n \"cockpit.subagentFollowMain\": \"跟随主对话\",\n \"cockpit.subagentOverride\": \"已指定渠道\",\n \"cockpit.subagentClearOverride\": \"清除\",\n \"capability.title\": \"能力测试 - {channel}\",\n \"capability.cancel\": \"取消测试\",\n \"console.capability.running\": \"测试进行中...\",\n \"capability.noResults\": \"尚未进行能力测试\",\n \"console.capability.lastResults\": \"上次测试结果\",\n \"capability.modelsLabel\": \"模型:\",\n \"capability.duration\": \"总耗时\",\n \"console.capability.protocolResults\": \"协议结果\",\n \"console.capability.compatible\": \"兼容协议\",\n \"capability.loadingTitle\": \"正在测试协议兼容性...\",\n \"capability.cancelling\": \"取消中...\",\n \"capability.cancelled\": \"已取消\",\n \"capability.partial\": \"部分可用\",\n \"capability.noCompatibleProtocols\": \"无兼容协议\",\n \"capability.modelQueued\": \"排队中\",\n \"capability.protocolRunning\": \"测试中\",\n \"capability.rpmLabel\": \"测试 RPM\",\n \"capability.progressSummary\": \"{done}/{total} 模型已完成\",\n \"capability.supported\": \"支持\",\n \"capability.unsupported\": \"不支持\",\n \"capability.table.protocol\": \"协议\",\n \"capability.table.status\": \"状态\",\n \"capability.table.successCount\": \"成功数/总数\",\n \"capability.table.latency\": \"延迟\",\n \"capability.table.streaming\": \"流式\",\n \"capability.table.actions\": \"操作\",\n \"capability.copyToTab\": \"复制到此 Tab\",\n \"capability.currentTab\": \"当前 Tab\",\n \"capability.convert\": \"{protocol} 转换\",\n \"capability.modelDetailsUnavailable\": \"模型详情暂不可用\",\n \"capability.tooltipLatency\": \"延迟\",\n \"capability.tooltipStreaming\": \"流式\",\n \"capability.modelStatus\": \"状态\",\n \"capability.actualModel\": \"实际模型\",\n \"capability.codexImageGeneration\": \"Codex 生图工具\",\n \"capability.codexImageGenerationSummary\": \"{supported}/{total} 个 Key 支持\",\n \"capability.codexImageGenerationStripped\": \"已通过剥离工具兼容\",\n \"capability.retryModel\": \"重测此模型\",\n \"capability.testModel\": \"点击测试\",\n \"capability.runModeReusedRunning\": \"复用进行中任务\",\n \"capability.runModeResumedCancelled\": \"恢复已取消任务\",\n \"capability.runModeCacheHit\": \"缓存结果\",\n \"capability.runModeReusedPreviousResults\": \"复用上次成功结果\",\n \"capability.lastResults\": \"上次测试结果\",\n \"capability.models\": \"模型\",\n \"capability.snapshotUpdated\": \"更新时间:{time}\",\n \"channelLogs.title\": \"频道日志\",\n \"channelLogs.status.pending\": \"等待中\",\n \"channelLogs.status.firstByte\": \"首字节\",\n \"channelLogs.status.streaming\": \"流式传输\",\n \"channelLogs.status.completed\": \"已完成\",\n \"channelLogs.status.failed\": \"失败\",\n \"channelLogs.status.cancelled\": \"已取消\",\n \"channelLogs.sourceCapabilityTest\": \"能力测试\",\n \"channelLogs.copyEntry\": \"复制此条日志\",\n \"channelLogs.copiedEntry\": \"已复制日志\",\n \"channelLogs.reasoning.original\": \"原始\",\n \"channelLogs.reasoning.actual\": \"发出\",\n \"channelLogs.retry\": \"重试\",\n \"channelLogs.duration.connect\": \"连接\",\n \"channelLogs.duration.firstByte\": \"首字节\",\n \"channelLogs.duration.total\": \"总耗时\",\n \"console.logs.model\": \"模型\",\n \"console.logs.baseUrl\": \"URL\",\n \"console.logs.key\": \"Key\",\n \"channelLogs.status.connecting\": \"连接中\",\n \"console.logs.duration\": \"耗时\",\n \"console.logs.error\": \"错误\",\n \"console.logs.entries\": \"条\",\n \"channelLogs.empty\": \"暂无日志\",\n \"toast.channelCopied\": \"渠道已复制到 {protocol} Tab\",\n \"toast.copyFailed\": \"复制渠道失败: {message}\",\n \"tooltip.circuitBreakerSettings\": \"调校台\",\n \"chart.close\": \"关闭\",\n \"chart.1h\": \"1h\",\n \"chart.6h\": \"6h\",\n \"chart.24h\": \"24h\",\n \"chart.today\": \"今日\",\n \"chart.7d\": \"1周\",\n \"chart.30d\": \"1月\",\n \"chart.90d\": \"3月\",\n \"chart.180d\": \"半年\",\n \"chart.365d\": \"1年\",\n \"chart.thisyear\": \"今年\",\n \"chart.traffic\": \"流量\",\n \"chart.tokens\": \"Token\",\n \"chart.cost\": \"花费\",\n \"chart.cache\": \"Cache\",\n \"chart.cacheRw\": \"Cache R/W\",\n \"chart.cacheRead\": \"Cache Read\",\n \"chart.cacheWrite\": \"Cache Write\",\n \"chart.totalRequests\": \"总请求数\",\n \"chart.requests\": \"请求数\",\n \"chart.successRate\": \"成功率\",\n \"chart.inputTokens\": \"输入 Token\",\n \"chart.outputTokens\": \"输出 Token\",\n \"chart.input\": \"输入\",\n \"chart.output\": \"输出\",\n \"chart.requestUnit\": \"请求\",\n \"chart.tokenUnit\": \"token\",\n \"chart.issueCount\": \"异常\",\n \"chart.total\": \"合计\",\n \"chart.totalCost\": \"总花费\",\n \"chart.collapse\": \"收起\",\n \"chart.expandModelStats\": \"展开模型统计\",\n \"chart.expandChart\": \"展开图表\",\n \"chart.noData\": \"暂无请求数据\",\n \"chart.noChannelMetrics\": \"暂无 {channel} 的指标数据\",\n \"chart.noModelData\": \"暂无模型统计数据\",\n \"chart.noModelRequestsInRange\": \"选定时间范围内没有模型请求记录\",\n \"chart.noKeyUsageInRange\": \"选定时间范围内没有 Key 使用数据\",\n \"chart.historyLoadFailed\": \"加载历史数据失败\",\n \"chart.modelStatsLoadFailed\": \"加载模型统计失败\",\n \"chart.globalStatsLoadFailed\": \"加载全局统计失败\",\n \"chart.noRequestsInRange\": \"选定时间范围内没有请求记录\",\n \"channelEditor.title.editSubtitle\": \"修改渠道配置\",\n \"channelEditor.nav.redirect\": \"模型重定向\",\n \"channelEditor.basic.name.placeholder\": \"例如 OpenAI 生产环境\",\n \"channelEditor.basic.website.placeholder\": \"https://openai.com\",\n \"channelEditor.auth.duplicateKey\": \"重复的 API Key\",\n \"channelEditor.auth.addNewApiKey.placeholder\": \"输入新的 API Key\",\n \"channelEditor.mapping.source.label\": \"源模型\",\n \"channelEditor.mapping.source.placeholder\": \"gpt-4o\",\n \"channelEditor.mapping.target.label\": \"目标模型\",\n \"channelEditor.mapping.target.placeholder\": \"claude-sonnet-4-6\",\n \"console.form.presets\": \"预设快速注入\",\n \"console.form.existingMappings\": \"已有条目映射\",\n \"console.form.mappings\": \"条\",\n \"console.form.toggleVision\": \"切换视觉通道\",\n \"console.form.visionFallback\": \"视觉回退配置\",\n \"console.form.addNewMapping\": \"添加新映射关系\",\n \"console.form.createMapping\": \"建立映射\",\n \"channelEditor.transport.proxyUrl.placeholder\": \"socks5://...\",\n \"channelEditor.transport.requestTimeout.placeholder\": \"60\",\n \"channelEditor.transport.responseHeaderTimeout.placeholder\": \"60\",\n \"channelEditor.transport.routePrefix.placeholder\": \"kimi\",\n \"channelEditor.rateLimit.window.hint\": \"滑动窗口时长\",\n \"channelEditor.streamTimeout.strategy\": \"策略\",\n \"channelEditor.streamTimeout.strategyInherit\": \"继承全局\",\n \"channelEditor.timeout.title\": \"超时策略\",\n \"channelEditor.timeout.hint\": \"全部以秒为单位;关闭表示继承全局设置\",\n \"channelEditor.toast.updated\": \"渠道已更新\",\n \"channelEditor.toast.added\": \"渠道已添加\",\n \"console.form.existingHeaders\": \"已配置标头\",\n \"console.form.addNewHeader\": \"添加新标头\",\n \"console.form.headerValuePlaceholder\": \"标头携带对应的 Value 内容...\",\n \"console.form.headersNote\": \"说明\",\n \"console.form.headersNoteInject\": \"自定义 Headers 将在每个请求中注入到上游\",\n \"console.form.headersNoteOverride\": \"支持覆盖默认 Headers(如 User-Agent、Authorization 等)\",\n \"console.form.headersNoteTemplate\": \"键名和值都支持模板变量(如果后端实现了变量替换)\",\n \"capability.fetchingModels\": \"正在获取模型列表...\",\n \"capability.selectModels\": \"选择要测试的模型\",\n \"capability.selectAll\": \"全选\",\n \"capability.deselectAll\": \"取消全选\",\n \"capability.noModelsFound\": \"未找到模型,请在下方手动添加\",\n \"capability.addModelLabel\": \"添加模型\",\n \"capability.addModelPlaceholder\": \"例如 claude-sonnet-4-6\",\n \"capability.notStarted\": \"待测试\",\n \"capability.compatibleProtocols\": \"兼容协议\",\n \"capability.modelDetails\": \"模型明细\",\n \"capability.attemptedModels\": \"尝试模型数: {count}\",\n \"capability.startedAt\": \"开始时间\",\n \"capability.testedAt\": \"完成时间\",\n \"capability.success\": \"成功\",\n \"capability.failed\": \"失败\",\n \"capability.failedTooltip\": \"测试失败\",\n \"capability.channelFallback\": \"渠道 #{id}\",\n \"capability.modelRunning\": \"测试中\",\n \"capability.modelSuccess\": \"可用\",\n \"capability.modelFailed\": \"失败\",\n \"capability.modelSkipped\": \"已跳过\",\n \"capability.reasonNotRun\": \"本轮未执行\",\n \"capability.reasonCancelled\": \"完成前已取消\",\n \"capability.reasonTimeout\": \"请求超时\",\n \"capability.noApiKeyError\": \"该渠道未配置 API Key,无法执行能力测试。\",\n \"capability.genericJobError\": \"能力测试失败:{message}\",\n \"capability.redirectTests\": \"模型重定向测试\",\n \"capability.redirectTestsDesc\": \"跨协议模型映射验证\",\n \"addChannel.quickInputPlaceholder\": \"粘贴配置片段,自动识别 Base URL 和 API Key(支持多行)\\nhttps://api.example.com\\nhttps://api-backup.example.com\\nsk-ant-api03-xxxxxxxxxxxxxxxx\\nsk-ant-api03-yyyyyyyyyyyyyyyy\",\n \"addChannel.modelListEmptyHint\": \"暂无模型候选。填写 Base URL 和 API Key 后,聚焦模型输入框会自动从 /models 获取。\",\n \"addChannel.modelCapabilitiesRowsHint\": \"模型重定向的目标模型会自动补到这里并优先套用内置预设;透传或未重定向的模型也可以手动添加。\",\n \"addChannel.modelCapabilityRedirectTargets\": \"重定向目标\",\n \"addChannel.modelCapabilitiesConfigured\": \"已配置模型能力\",\n \"addChannel.modelCapabilityModelLabel\": \"实际模型\",\n \"addChannel.modelCapabilityModelPlaceholder\": \"选择或输入上游实际模型\",\n \"addChannel.contextTokensShort\": \"上下文\",\n \"addChannel.outputTokensShort\": \"输出\",\n \"addChannel.thinkingModeLabel\": \"思考模式\",\n \"addChannel.reasoningEffortsLabel\": \"思考强度\",\n \"addChannel.modelCapabilityBuiltinMatched\": \"预设:{pattern}\",\n \"addChannel.modelCapabilitiesRowsInvalid\": \"请检查模型能力行,模型名不能为空,tokens 和价格必须是非负数。\",\n \"addChannel.inputCacheHitPriceLabel\": \"缓存命中输入价\",\n \"addChannel.inputCacheMissPriceLabel\": \"缓存未命中输入价\",\n \"addChannel.outputPriceLabel\": \"输出价\",\n \"addChannel.pricingTitle\": \"费率设定\",\n \"addChannel.pricingCurrencyLabel\": \"币种\",\n \"addChannel.pricingUnitLabel\": \"单位\",\n \"addChannel.defaultOutputTokensMeta\": \"默认输出 {tokens}\",\n \"addChannel.recommendedOutputTokensMeta\": \"推荐输出 {tokens}\",\n \"addChannel.modelPricingUnitHint\": \"每 100 万 tokens\",\n \"addChannel.embeddingCompatibilityTitle\": \"Embedding 兼容性\",\n \"addChannel.embeddingCompatibilityHint\": \"key 使用模型重定向后的实际上游模型名;fallback 只会在相同 embeddingSpaceId/实际模型、维度和归一化语义内发生。\",\n \"addChannel.embeddingCompatibilityRedirectTargets\": \"重定向目标\",\n \"addChannel.embeddingCompatibilityConfigured\": \"已配置 Embedding 空间\",\n \"addChannel.embeddingCapabilityModelLabel\": \"实际模型\",\n \"addChannel.embeddingCapabilityModelPlaceholder\": \"选择或输入上游 Embedding 模型\",\n \"addChannel.embeddingSpaceIdLabel\": \"向量空间 ID\",\n \"addChannel.embeddingSpaceIdPlaceholder\": \"留空时使用实际模型名\",\n \"addChannel.embeddingDimensionsLabel\": \"默认维度\",\n \"addChannel.embeddingSupportedDimensionsLabel\": \"支持的请求维度\",\n \"addChannel.embeddingSupportedDimensionsPlaceholder\": \"例如 1024, 1536\",\n \"addChannel.embeddingNormalizedLabel\": \"归一化\",\n \"addChannel.embeddingNormalizedUnknown\": \"未指定\",\n \"addChannel.embeddingNormalizedTrue\": \"已归一化\",\n \"addChannel.embeddingNormalizedFalse\": \"未归一化\",\n \"addChannel.embeddingCapabilitiesRowsInvalid\": \"请检查 Embedding 兼容性行:实际模型不能为空,维度必须为正整数,支持维度列表只能包含正整数。\",\n \"channelDiscovery.title\": \"发现可用配置\",\n \"channelDiscovery.hint\": \"用当前 Base URL 和 API Key 直接探测模型、协议、重定向和适配开关。\",\n \"channelDiscovery.button\": \"发现配置\",\n \"channelDiscovery.failed\": \"发现配置失败\",\n \"channelDiscovery.missingBaseUrl\": \"请先填写 Base URL。\",\n \"channelDiscovery.missingApiKey\": \"请先填写 API Key。\",\n \"channelDiscovery.missingServiceType\": \"请先选择服务类型。\",\n \"channelDiscovery.recommendedKind\": \"推荐 {kind}\",\n \"channelDiscovery.modelsFound\": \"模型 {count}\",\n \"channelDiscovery.mapping\": \"推荐重定向\",\n \"channelDiscovery.reasoning\": \"推荐思考强度\",\n \"channelDiscovery.reasoningNote\": \"思考强度是按源模型角色给出的默认值,发现流程目前不逐档测试。\",\n \"channelDiscovery.compat\": \"适配开关\",\n \"channelDiscovery.capabilities\": \"能力验证\",\n \"channelDiscovery.capabilityToolCalls\": \"工具调用\",\n \"channelDiscovery.capabilityVision\": \"图片输入\",\n \"channelDiscovery.capabilityImageGeneration\": \"Codex 生图工具\",\n \"channelDiscovery.capabilityThinkingPassback\": \"思考回传\",\n \"channelDiscovery.capabilitySupported\": \"支持\",\n \"channelDiscovery.capabilityUnsupported\": \"不支持\",\n \"channelDiscovery.capabilityRequired\": \"需要\",\n \"channelDiscovery.capabilityNotRequired\": \"不需要\",\n \"channelDiscovery.apply\": \"应用推荐\",\n \"channelDiscovery.applied\": \"已应用发现推荐,请检查后保存\",\n \"autopilot.quickAdd.alreadyAdded\": \"该 Base URL 已添加为渠道「{name}」,提交后会自动追加新密钥。\",\n \"autopilot.costPreference.balanced\": \"均衡\",\n \"autopilot.costPreference.cost_first\": \"成本优先\",\n \"autopilot.costPreference.quality_first\": \"质量优先\",\n \"autopilot.costPreferenceDesc.balanced\": \"在质量和成本之间取得平衡。\",\n \"autopilot.costPreferenceDesc.cost_first\": \"优先选择低成本渠道,适当容忍质量下降。\",\n \"autopilot.costPreferenceDesc.quality_first\": \"优先选择高质量供应商,容忍较高成本。\",\n \"autopilot.mode.active\": \"活跃\",\n \"autopilot.mode.assist\": \"辅助\",\n \"autopilot.mode.auto\": \"自动\",\n \"autopilot.mode.dry_run\": \"试运行\",\n \"autopilot.mode.off\": \"关闭\",\n \"autopilot.mode.shadow\": \"影子\",\n \"autopilot.modeDesc.assist\": \"辅助模式:重排候选列表顺序,但不改变最终选择。\",\n \"autopilot.modeDesc.auto\": \"全自动模式:过滤 + 重排,满足准入门槛后接管调度。\",\n \"autopilot.modeDesc.off\": \"完全关闭智能路由,不计算不记录路由 trace。\",\n \"autopilot.modeDesc.shadow\": \"只计算并记录路由决策,不影响真实调度(推荐)。\",\n \"autopilot.modePanel.confirmMessage\": \"即将切换到 {mode} 模式。该模式会影响真实调度结果,请确认您了解其行为。是否继续?\",\n \"autopilot.readiness.ready\": \"自动模式已达到上线门槛\",\n \"autopilot.readiness.collecting\": \"正在积累自动模式安全样本\",\n \"autopilot.readiness.progress\": \"有效样本 {samples}/{requiredSamples},连续观测 {hours}/{requiredHours} 小时\",\n \"autopilot.readiness.successRate\": \"成功率\",\n \"autopilot.readiness.fallbackRate\": \"Fallback\",\n \"autopilot.readiness.failOpenRate\": \"Fail-open\",\n \"autopilot.readiness.lastRollback\": \"{time} 检测到连续 SLO 回归,系统已自动从 auto 降回 assist。\",\n \"autopilot.readiness.reason.telemetry_unavailable\": \"路由遥测不可用\",\n \"autopilot.readiness.reason.telemetry_query_failed\": \"路由遥测查询失败\",\n \"autopilot.readiness.reason.insufficient_samples\": \"有效样本不足\",\n \"autopilot.readiness.reason.insufficient_observation_time\": \"连续观测时间不足\",\n \"autopilot.readiness.reason.success_rate_below_threshold\": \"成功率低于 95%\",\n \"autopilot.readiness.reason.fallback_rate_above_threshold\": \"Fallback 率高于 10%\",\n \"autopilot.readiness.reason.failopen_rate_above_threshold\": \"Fail-open 率高于 2%\",\n \"autopilot.readiness.reason.insufficient_comparison_samples\": \"近期与基线对比样本不足\",\n \"autopilot.readiness.reason.recent_success_regression\": \"近期成功率明显下降\",\n \"autopilot.readiness.reason.recent_fallback_regression\": \"近期 Fallback 率明显上升\",\n \"autopilot.readiness.reason.recent_latency_regression\": \"近期 p95 延迟明显上升\",\n \"autopilot.modePanel.confirmTitle\": \"确认切换模式\",\n \"autopilot.modePanel.costPreference\": \"价格偏好\",\n \"autopilot.modePanel.killSwitch\": \"急停开关(Kill Switch)\",\n \"autopilot.modePanel.killSwitchActive\": \"急停开关已激活,智能路由已强制关闭。请先关闭急停开关以启用其他模式。\",\n \"autopilot.modePanel.killSwitchHint\": \"开启后强制关闭智能路由,优先级高于路由模式选择。\",\n \"autopilot.modePanel.reset\": \"重置\",\n \"autopilot.modePanel.routingMode\": \"路由模式\",\n \"autopilot.modePanel.save\": \"保存配置\",\n \"autopilot.modePanel.title\": \"全局策略\",\n \"autopilot.title\": \"智能路由 Autopilot\",\n \"autopilot.traceStats.mismatchRate\": \"不一致率\",\n \"autopilot.traceStats.mismatches\": \"不一致数\",\n \"autopilot.traceStats.modeDistribution\": \"按模式分布\",\n \"autopilot.traceStats.taskClassDistribution\": \"按任务类别分布\",\n \"autopilot.traceStats.title\": \"路由追踪统计\",\n \"autopilot.traceStats.total\": \"总追踪数\",\n \"autopilot.traceTable.candidates\": \"候选渠道\",\n \"autopilot.traceTable.col.kind\": \"类型\",\n \"autopilot.traceTable.col.match\": \"一致\",\n \"autopilot.traceTable.col.mode\": \"模式\",\n \"autopilot.traceTable.col.outcome\": \"结果\",\n \"autopilot.traceTable.col.model\": \"模型\",\n \"autopilot.traceTable.col.shadowVsActual\": \"Shadow vs 实际\",\n \"autopilot.traceTable.col.taskClass\": \"任务类别\",\n \"autopilot.traceTable.col.time\": \"时间\",\n \"autopilot.traceTable.empty\": \"暂无追踪记录\",\n \"autopilot.traceTable.mismatchOnly\": \"只看不一致\",\n \"autopilot.traceTable.no\": \"否\",\n \"autopilot.traceTable.sortReasons\": \"排序原因\",\n \"autopilot.traceTable.title\": \"路由决策追踪\",\n \"autopilot.traceTable.yes\": \"是\",\n \"healthCenter.col.channel\": \"渠道\",\n \"healthCenter.col.endpoints\": \"Endpoints\",\n \"healthCenter.col.expand\": \"详情\",\n \"healthCenter.col.kind\": \"类型\",\n \"healthCenter.col.status\": \"状态\",\n \"healthCenter.col.successRate\": \"成功率\",\n \"healthCenter.detail.baseUrl\": \"Base URL\",\n \"healthCenter.detail.confidence\": \"置信度\",\n \"healthCenter.detail.consecutiveFail\": \"连续失败\",\n \"healthCenter.detail.evidence\": \"证据\",\n \"healthCenter.detail.keyHash\": \"Key\",\n \"healthCenter.detail.lastSuccess\": \"最近成功\",\n \"healthCenter.detail.p95\": \"P95 延迟\",\n \"healthCenter.detail.firstByteP95\": \"P95 首字节\",\n \"healthCenter.detail.qualityTier\": \"质量\",\n \"healthCenter.detail.speedTier\": \"速度\",\n \"healthCenter.detail.stabilityTier\": \"稳定性\",\n \"healthCenter.detail.successRate15m\": \"成功率(15m)\",\n \"healthCenter.detail.successRate1h\": \"成功率(1h)\",\n \"healthCenter.detail.suggestedAction\": \"建议操作\",\n \"healthCenter.detail.updatedAt\": \"更新时间\",\n \"healthCenter.kind.chat\": \"Chat\",\n \"healthCenter.kind.gemini\": \"Gemini\",\n \"healthCenter.kind.images\": \"Images\",\n \"healthCenter.kind.messages\": \"Messages\",\n \"healthCenter.kind.responses\": \"Responses\",\n \"healthCenter.kind.vectors\": \"Vectors\",\n \"healthCenter.noChannels\": \"暂无渠道健康数据\",\n \"healthCenter.state.dead\": \"死亡\",\n \"healthCenter.state.degraded\": \"降级\",\n \"healthCenter.state.healthy\": \"健康\",\n \"healthCenter.state.limited\": \"限流\",\n \"healthCenter.state.misconfigured\": \"疑似配置错\",\n \"healthCenter.state.unknown\": \"未知\",\n \"healthCenter.title\": \"渠道健康中心\",\n \"healthCenter.totalChannels\": \"渠道总数\",\n \"healthCenter.totalEndpoints\": \"Endpoint 总数\",\n \"nav.health\": \"健康中心\",\n \"nav.healthDesc\": \"渠道健康状态总览\",\n \"nav.autopilot\": \"智能路由\",\n \"nav.autopilotDesc\": \"自动托管调度策略\",\n \"tab.healthTitle\": \"渠道健康中心\",\n \"tab.autopilotTitle\": \"智能路由 Autopilot\",\n \"channelHealth.stateLabel\": \"健康状态\",\n \"channelHealth.endpointsHealthy\": \"个端点健康\",\n \"channelHealth.avgSuccessRate\": \"平均成功率\",\n \"channelHealth.inconsistentWarning\": \"{count} 个端点状态不一致\",\n \"healthCenter.changelog.title\": \"最近变更\",\n \"healthCenter.changelog.empty\": \"暂无变更记录\",\n \"healthCenter.changelog.live\": \"实时\",\n \"healthCenter.changelog.connecting\": \"连接中…\",\n \"healthCenter.changelog.disconnected\": \"实时更新已断开,正在重连…\",\n \"healthCenter.changelog.type.healthChanged\": \"健康状态变化\",\n \"healthCenter.changelog.type.profileUpdated\": \"画像更新\",\n \"healthCenter.changelog.type.discoveryCompleted\": \"自动发现完成\",\n \"healthCenter.changelog.type.autoMappingApplied\": \"自动写入模型配置\",\n \"channelHealth.originOfficial\": \"官方 API\",\n \"channelHealth.originRelay\": \"中转\",\n \"channelHealth.originCommunity\": \"公益\",\n \"channelHealth.originUnknown\": \"来源未知\",\n \"channelHealth.poolFree\": \"白嫖池\",\n \"channelHealth.poolTemp\": \"临时池\",\n \"cockpitOverview.title\": \"驾驶舱总览\",\n \"cockpitOverview.health\": \"健康概览\",\n \"cockpitOverview.channels\": \"渠道数\",\n \"cockpitOverview.endpoints\": \"Endpoint 数\",\n \"cockpitOverview.subscriptions\": \"订阅概览\",\n \"cockpitOverview.totalSubscriptions\": \"订阅总数\",\n \"cockpitOverview.manualIntents\": \"人工意图\",\n \"cockpitOverview.activeIntents\": \"活跃意图\",\n \"cockpitOverview.totalIntents\": \"意图总数\",\n \"cockpitOverview.todoList\": \"待办事项\",\n \"cockpitOverview.empty\": \"暂无数据,请先配置渠道和订阅。\",\n \"cockpitOverview.recommendations\": \"渠道推荐\",\n \"cockpitOverview.noRecommendations\": \"暂无推荐,用量数据积累后会在此展示建议。\",\n \"cockpitOverview.usageCount\": \"近期使用 {count} 次\",\n \"cockpitOverview.currentChannel\": \"当前主用渠道\",\n \"cockpitOverview.recommendedChannel\": \"推荐尝试\",\n \"cockpitOverview.scoreDelta\": \"综合分差 +{delta}\",\n \"cockpitOverview.activeTrials\": \"活跃试用意图\",\n \"cockpitOverview.noActiveTrials\": \"暂无活跃试用意图。可在渠道中心点击“试用”创建。\",\n \"cockpitOverview.trialExpired\": \"已到期\",\n \"cockpitOverview.trialStats\": \"命中 {hit} · 成功率 {rate} · 回退 {fallback}\",\n \"cockpitOverview.trialStatsLabel\": \"试用统计\",\n \"cockpitOverview.remaining\": \"剩余时间\",\n \"cockpitOverview.status\": \"状态\",\n \"manualIntent.field.intentType\": \"意图类型\",\n \"manualIntent.field.channelUid\": \"渠道 UID(可选)\",\n \"manualIntent.field.model\": \"模型(可选)\",\n \"manualIntent.intentType.model_trial\": \"模型试用\",\n \"manualIntent.intentType.channel_trial\": \"渠道试用\",\n \"manualIntent.intentType.endpoint_trial\": \"Endpoint 试用\",\n \"manualIntent.intentType.session_pin\": \"会话锁定\",\n \"manualIntent.status.active\": \"活跃\",\n \"manualIntent.status.expired\": \"已到期\",\n \"manualIntent.status.exhausted\": \"已耗尽\",\n \"manualIntent.status.disabled\": \"已停用\",\n \"manualIntent.actions.end\": \"结束\",\n \"manualIntent.field.channelKind\": \"渠道类型\",\n \"manualIntent.field.name\": \"名称(可选)\",\n \"manualIntent.field.metricsKey\": \"Endpoint 指标键(可选)\",\n \"manualIntent.field.mappedModel\": \"映射模型(可选)\",\n \"manualIntent.field.sessionId\": \"会话 ID\",\n \"manualIntent.field.agentRoles\": \"Agent 角色\",\n \"manualIntent.field.taskClasses\": \"任务类别\",\n \"manualIntent.field.trafficPercent\": \"流量比例\",\n \"manualIntent.field.ttlMinutes\": \"有效时长(分钟)\",\n \"manualIntent.field.maxRequests\": \"最大请求数\",\n \"manualIntent.field.maxEstimatedCost\": \"最大预估成本($)\",\n \"manualIntent.field.fallbackOnFailure\": \"失败时回退\",\n \"manualIntent.field.requireHardConstraints\": \"强制硬性约束\",\n \"manualIntent.field.reason\": \"备注\",\n \"manualIntent.agentRole.main\": \"主 Agent\",\n \"manualIntent.agentRole.subagent\": \"子 Agent\",\n \"manualIntent.taskClass.supervisor\": \"监督\",\n \"manualIntent.taskClass.worker\": \"工作\",\n \"manualIntent.taskClass.lightweight\": \"轻量\",\n \"manualIntent.taskClass.vision\": \"视觉\",\n \"manualIntent.taskClass.long_context\": \"长上下文\",\n \"manualIntent.taskClass.image_generation\": \"图像生成\",\n \"manualIntent.taskClass.embedding\": \"向量嵌入\",\n \"manualIntent.validation.channelKindRequired\": \"渠道类型为必填项\",\n \"manualIntent.validation.sessionIdRequired\": \"会话锁定需要填写会话 ID\",\n \"manualIntent.actions.create\": \"创建试用\",\n \"manualIntent.actions.cancel\": \"取消\",\n \"manualIntent.error.createFailed\": \"创建试用意图失败\"\n}\n"}, "4da6d77e15a5c2ac4bc15ce1ad1a68cfdbd2e2d0": {"t": 1785846934.3533623, "n": 75868, "h": "9c97c9a0cadee707", "c": "{\n \"common.gatewayLabel\": \"CCX CORE\",\n \"common.online\": \"GATEWAY ONLINE\",\n \"common.connecting\": \"CONNECTING...\",\n \"common.offline\": \"GATEWAY OFFLINE\",\n \"common.autoGenerated\": \"自动生成\",\n \"common.refreshWebUI\": \"刷新 Web UI\",\n \"common.version\": \"当前版本\",\n \"common.gatewayPort\": \"网关端口\",\n \"common.daemonPid\": \"守护 PID\",\n \"common.autoStart\": \"开机自启\",\n \"common.autoStartOn\": \"已开启\",\n \"common.autoStartOff\": \"已关闭\",\n \"common.serviceHealthy\": \"运行正常\",\n \"common.serviceStarting\": \"网关启动中\",\n \"common.serviceDisconnected\": \"服务已断开\",\n \"common.settings\": \"设置\",\n \"common.save\": \"保存\",\n \"common.cancel\": \"取消\",\n \"common.clearSearch\": \"清除搜索\",\n \"common.retry\": \"重试\",\n \"adminApi.error.serviceNotRunning\": \"服务未运行,无法发送请求\",\n \"adminApi.error.networkUnavailable\": \"服务未运行或网络不可达,请检查后端是否已启动\",\n \"nav.status\": \"网关监控\",\n \"nav.statusDesc\": \"实时状态及核心日志\",\n \"nav.agent\": \"Agent 配置\",\n \"nav.agentDesc\": \"本地开发代理控制\",\n \"nav.channels\": \"渠道中心\",\n \"nav.channelsDesc\": \"协议级渠道编排\",\n \"nav.subscriptions\": \"订阅中心\",\n \"nav.subscriptionsDesc\": \"账号授权与订阅接入\",\n \"nav.cockpit\": \"驾驶舱\",\n \"nav.cockpitDesc\": \"会话与子代理看板\",\n \"nav.env\": \"环境参数\",\n \"nav.envDesc\": \"网关配置文件编辑\",\n \"nav.dashboard\": \"管理面板\",\n \"nav.dashboardDesc\": \"原生管理面板\",\n \"sidebar.managementSection\": \"管理\",\n \"tab.statusTitle\": \"网关状态监控\",\n \"tab.agentTitle\": \"Agent 代理配置\",\n \"tab.channelsTitle\": \"渠道管理控制台\",\n \"tab.subscriptionsTitle\": \"订阅中心\",\n \"tab.cockpitTitle\": \"驾驶舱\",\n \"tab.envTitle\": \"环境参数管理\",\n \"tab.dashboardTitle\": \"管理面板\",\n \"sidebar.language\": \"语言\",\n \"sidebar.languageEnglish\": \"English\",\n \"sidebar.languageChinese\": \"中文\",\n \"setup.loading\": \"正在初始化 CCX 控制台\",\n \"setup.title\": \"CCX Desktop 初始配置\",\n \"setup.description\": \"PROXY_ACCESS_KEY 是 AI Agent 通过 CCX 代理访问上游 API 的身份凭证,所有调用方必须持有此密钥。\",\n \"setup.regenerate\": \"重新生成\",\n \"setup.regenerateTitle\": \"重新生成随机密钥\",\n \"setup.hide\": \"隐藏\",\n \"setup.show\": \"显示\",\n \"setup.copied\": \"已复制\",\n \"setup.copyKey\": \"复制密钥\",\n \"setup.configPath\": \"配置文件路径\",\n \"setup.copyPath\": \"复制路径\",\n \"setup.hint\": \"保存后 CCX 将自动启动。后续可在主界面【环境参数】页继续调整其他配置。\",\n \"setup.saving\": \"正在保存并启动...\",\n \"setup.submit\": \"完成配置并启动\",\n \"webui.notRunning\": \"CCX 服务尚未启动,无法显示 Web UI。\",\n \"webui.openInBrowser\": \"浏览器打开\",\n \"metrics.gatewayPort\": \"网关端口\",\n \"metrics.uptime\": \"运行时长\",\n \"metrics.channels\": \"调度信道\",\n \"metrics.version\": \"网关版本\",\n \"actions.start\": \"启动服务\",\n \"actions.stop\": \"停止服务\",\n \"actions.restart\": \"重启服务\",\n \"actions.openWebUI\": \"打开管理面板\",\n \"actions.openBrowser\": \"浏览器直达\",\n \"actions.refreshStatus\": \"刷新当前状态\",\n \"details.title\": \"服务详情\",\n \"details.binary\": \"二进制\",\n \"details.binaryMissing\": \"未发现\",\n \"details.dataDir\": \"数据目录\",\n \"details.dataDirMissing\": \"未设置\",\n \"details.healthStatus\": \"健康状态\",\n \"details.revealDir\": \"打开所在目录\",\n \"details.openDir\": \"打开目录\",\n \"channel.headerEyebrow\": \"Channel Preset Center\",\n \"channel.title\": \"渠道中心\",\n \"channel.description\": \"统一把 DeepSeek、MiMo、Kimi、GLM、SenseNova、MiniMax、DashScope、讯飞星辰等 Key 可同时用于 Agent 直连和 CCX 统一渠道池,复杂开关由预设自动处理。\",\n \"channel.hasKey\": \"已有 Key\",\n \"channel.promo\": \"通过推广链接注册领取专属福利\",\n \"channel.console\": \"访问官方控制台\",\n \"channel.target\": \"添加目标\",\n \"channel.planLabel\": \"套餐 / Base URL\",\n \"channel.apiKeyLabel\": \"API Key\",\n \"channel.baseUrlPrefix\": \"Base URL\",\n \"channel.keySavedPlaceholder\": \"已保存,留空则复用该 Provider Key\",\n \"channel.keyInputPlaceholder\": \"输入 API Key,仅保存在本机 Desktop 配置中\",\n \"channel.missingKey\": \"请填写 API Key,或先在 Agent 配置中保存该 Provider 的 key。\",\n \"channel.reuseKey\": \"将复用本机已保存的 {provider} Key:{key}\",\n \"channel.name\": \"渠道名称\",\n \"channel.nameHint\": \"同名渠道会被直接覆盖更新;如需新建独立渠道,请改用不同名称。\",\n \"channel.presetWrites\": \"预设将自动写入\",\n \"channel.capabilityHint\": \"能力开关:reasoning / vision / model list / 兼容字段会按 Provider 自动配置。\",\n \"channel.addToCCX\": \"添加到 CCX\",\n \"channel.badgeDirectAgent\": \"Agent 直连\",\n \"channel.badgeNativeMessages\": \"Messages 原生\",\n \"copilotOAuth.description\": \"使用 GitHub OAuth 获取 Copilot 授权,授权成功后会把 GitHub OAuth token 添加为本渠道密钥。该 token 仅用于与 GitHub 交互,CCX 会自动换成短期 Copilot token。\",\n \"copilotOAuth.button\": \"使用 GitHub Copilot 登录\",\n \"copilotOAuth.userCode\": \"授权码\",\n \"copilotOAuth.openAuthorize\": \"打开 GitHub 授权\",\n \"copilotOAuth.waiting\": \"等待 GitHub 授权完成...\",\n \"copilotOAuth.success\": \"授权成功,GitHub OAuth token 已添加到本渠道。\",\n \"copilotOAuth.cancel\": \"取消授权\",\n \"copilotOAuth.expired\": \"授权码已过期,请重新发起授权。\",\n \"subscription.headerEyebrow\": \"Subscription\",\n \"subscription.title\": \"订阅中心\",\n \"subscription.description\": \"集中管理需要账号授权的订阅服务,并将授权结果添加为 CCX 渠道。\",\n \"subscription.authorized\": \"已授权\",\n \"subscription.copilotDescription\": \"使用 GitHub OAuth 获取 Copilot 授权,同一份授权可添加到 Claude、Chat、Codex、Gemini 任一协议渠道。该 token 仅用于与 GitHub 交互,CCX 会自动换成短期 Copilot token。\",\n \"subscription.checkingChannel\": \"正在检查现有 Copilot 渠道...\",\n \"subscription.verifying\": \"正在校验 GitHub 账号...\",\n \"subscription.addingCopilotAccount\": \"正在加入账号到所选渠道...\",\n \"subscription.authorizeCopilot\": \"授权 GitHub Copilot\",\n \"subscription.authorizeAndAddAccount\": \"授权并加入新账号\",\n \"subscription.addExistingAccount\": \"加入已授权账号\",\n \"subscription.copilotAuthorizationSavedOnly\": \"已获授权;当前未检测到 {target} 渠道,点击下方按钮即可添加。\",\n \"subscription.retryAddAccount\": \"重试加入账号\",\n \"subscription.accountsTitle\": \"{target} 已授权账号({count})\",\n \"subscription.accountCountShort\": \"{count} 个账号\",\n \"subscription.accountsEmpty\": \"当前 {target} 渠道还没有已授权账号。\",\n \"subscription.accountsSummary\": \"{target} 渠道已累积 {count} 个授权账号\",\n \"subscription.accountUnnamed\": \"未命名账号\",\n \"subscription.accountRemove\": \"移除该账号\",\n \"subscription.accountRemoveConfirm\": \"确认移除\",\n \"subscription.verifyFailed\": \"未能获取该 GitHub 账号信息,请重试\",\n \"subscription.channelResolveFailed\": \"渠道创建后未能定位,请重试\",\n \"subscription.targetLabel\": \"添加到协议渠道\",\n \"subscription.channelExists\": \"已存在\",\n \"subscription.targetClaude\": \"Claude\",\n \"subscription.targetClaudeDesc\": \"添加到 Messages 渠道,供 Claude Code 协议入口使用\",\n \"subscription.targetChat\": \"Chat\",\n \"subscription.targetChatDesc\": \"添加到 OpenAI Chat Completions 渠道\",\n \"subscription.targetCodex\": \"Codex\",\n \"subscription.targetCodexDesc\": \"添加到 Responses 渠道,供 Codex 协议入口使用\",\n \"subscription.targetGemini\": \"Gemini\",\n \"subscription.targetGeminiDesc\": \"添加到 Gemini 协议渠道\",\n \"subscription.field.uid\": \"订阅 ID\",\n \"subscription.field.name\": \"显示名称\",\n \"subscription.field.notes\": \"备注\",\n \"subscription.newApi.connect\": \"接入 new-api\",\n \"subscription.newApi.step1Title\": \"第一步:校验 Token\",\n \"subscription.newApi.step2Title\": \"第二步:确认接入信息\",\n \"subscription.newApi.baseUrl\": \"new-api 地址\",\n \"subscription.newApi.accessToken\": \"Access Token\",\n \"subscription.newApi.userId\": \"用户 ID(可选)\",\n \"subscription.newApi.authTokenMode\": \"Token 模式\",\n \"subscription.newApi.verify\": \"校验\",\n \"subscription.newApi.reVerify\": \"重新校验\",\n \"subscription.newApi.accountPreview\": \"账号预览\",\n \"subscription.newApi.username\": \"用户名\",\n \"subscription.newApi.quota\": \"额度\",\n \"subscription.newApi.usedQuota\": \"已用额度\",\n \"subscription.newApi.availableModels\": \"可用模型数\",\n \"subscription.newApi.groups\": \"分组倍率\",\n \"subscription.newApi.channelKind\": \"渠道类型\",\n \"subscription.newApi.channelName\": \"渠道名称(可选)\",\n \"subscription.newApi.provisionGroup\": \"接入分组(可选)\",\n \"subscription.newApi.provision\": \"接入\",\n \"subscription.newApi.provisionSuccess\": \"已自动创建密钥并接入渠道,密钥已写入渠道配置,不会再次显示。\",\n \"subscription.newApi.discoveryStarted\": \"模型发现已启动。\",\n \"channel.preset.deepseek.label\": \"DeepSeek\",\n \"channel.preset.deepseek.description\": \"V4-Pro / V4-Flash 双旗舰推理模型,突破性成本。完全开源(MIT),以极具竞争力的价格提供前沿级性能。\",\n \"channel.preset.mimo.label\": \"MiMo\",\n \"channel.preset.mimo.description\": \"小米自研 AI 平台,面向 Agent 时代打造。旗舰 MiMo-V2.5-Pro 具备万亿参数与百万上下文窗口,赋能小米跨设备、车、家的智能生态。\",\n \"channel.preset.compshare.label\": \"优云智算套餐\",\n \"channel.preset.compshare.description\": \"优云智算是 UCloud 旗下 AI 云平台,提供高性价比国内 AI 模型 Agent Plan 套餐,支持包月订阅或按量付费(49 元/月起),同时提供官方海外模型稳定接入,支持 Claude Code、Codex 与 API 集成,具备企业级高并发、7×24 技术支持和自助开票能力;通过推广链接注册可领取 5 元平台试用金。\",\n \"channel.preset.runapi.label\": \"RunAPI\",\n \"channel.preset.runapi.description\": \"RunAPI 是高效稳定的API OpenRouter平替平台,一个 API Key 即可访问 OpenAI、Claude、Gemini、DeepSeek、Grok 等 150+ 主流模型,低至 1 折,极其稳定,可以无缝兼容 Claude Code、OpenClaw 等工具。RunAPI 为 CCX用户提供专属福利:注册联系管理员即可领取¥7的免费额度\",\n \"channel.preset.kimi.label\": \"Kimi / Moonshot\",\n \"channel.preset.kimi.description\": \"Moonshot AI 智能助手平台。旗舰 Kimi K2.6 支持原生多模态,以超长上下文和 Agent Swarm 能力著称。\",\n \"channel.preset.glm.label\": \"GLM / BigModel\",\n \"channel.preset.glm.description\": \"清华系智谱 AI MaaS 平台。旗舰 GLM-5.1 在编程领域达到前沿模型水平,港股上市,全栈华为昇腾芯片训练。\",\n \"channel.preset.sensenova.label\": \"SenseNova\",\n \"channel.preset.sensenova.description\": \"商汤日日新 LLM API 平台,兼容 OpenAI 与 Anthropic 协议,预设使用 GLM-5.2、DeepSeek V4 Flash 与 SenseNova 6.7 Flash-Lite 多模态回退。\",\n \"channel.preset.minimax.label\": \"MiniMax\",\n \"channel.preset.minimax.description\": \"全球多模态 AI 领导者(港股上市)。旗舰 M3 具备百万上下文、原生视觉与 Agent 能力;海螺视频/音频模型位居全球前列;超 70% 收入来自海外市场。\",\n \"channel.preset.dashscope.label\": \"阿里云 DashScope\",\n \"channel.preset.dashscope.description\": \"阿里云一站式 LLM 平台,集成通义千问及第三方模型,全模态覆盖,提供按量、编程套餐与 Token Plan 选项。\",\n \"channel.preset.opencode-zen.label\": \"OpenCode Zen / Go\",\n \"channel.preset.opencode-zen.description\": \"OpenCode 官方精选模型网关,Go 订阅套餐与 Zen 按量套餐共用同一套编程模型预设。\",\n \"channel.preset.opencode-go.label\": \"OpenCode Go\",\n \"channel.preset.opencode-go.description\": \"低成本开源编程模型订阅服务($5/月起),支持 Messages、Chat、Responses 三种协议。\",\n \"channel.preset.deepseek.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.deepseek.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.deepseek.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.deepseek.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.mimo.plan.anthropic.label\": \"按量 (Anthropic)\",\n \"channel.preset.mimo.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.mimo.plan.openai-chat.label\": \"按量 (OpenAI)\",\n \"channel.preset.mimo.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.mimo.plan.token-cn.label\": \"Token Plan - 中国 (OpenAI)\",\n \"channel.preset.mimo.plan.token-cn.description\": \"中国区 Token Plan Chat / Responses 通用入口\",\n \"channel.preset.mimo.plan.token-sgp.label\": \"Token Plan - 新加坡 (OpenAI)\",\n \"channel.preset.mimo.plan.token-sgp.description\": \"新加坡区 Token Plan Chat / Responses 通用入口\",\n \"channel.preset.mimo.plan.token-ams.label\": \"Token Plan - 欧洲 (OpenAI)\",\n \"channel.preset.mimo.plan.token-ams.description\": \"欧洲区 Token Plan Chat / Responses 通用入口\",\n \"channel.preset.mimo.plan.token-cn-anthropic.label\": \"Token Plan - 中国 (Anthropic)\",\n \"channel.preset.mimo.plan.token-cn-anthropic.description\": \"中国区 Token Plan Claude Messages 原生入口\",\n \"channel.preset.mimo.plan.token-sgp-anthropic.label\": \"Token Plan - 新加坡 (Anthropic)\",\n \"channel.preset.mimo.plan.token-sgp-anthropic.description\": \"新加坡区 Token Plan Claude Messages 原生入口\",\n \"channel.preset.mimo.plan.token-ams-anthropic.label\": \"Token Plan - 欧洲 (Anthropic)\",\n \"channel.preset.mimo.plan.token-ams-anthropic.description\": \"欧洲区 Token Plan Claude Messages 原生入口\",\n \"channel.preset.compshare.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.compshare.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.compshare.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.compshare.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.runapi.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.runapi.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.runapi.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.runapi.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.unity2.label\": \"Unity2.ai\",\n \"channel.preset.unity2.description\": \"Unity2.ai 是面向个人开发者、团队、企业的高性能 AI 模型 API 中转平台,长期服务国内头部企业,日均承载超 300 亿 token 调用,支持 5000 RPM 级高并发。一个 API Key 即可适配 Claude Code、Codex、OpenAI 模型、IDE 插件和 Agent 工作流等场景。具备企业级稳定供应能力,在高并发、持续调用和团队集中采购场景下依然保持低延迟、高可用。现在注册 Unity2.ai 可领取 $2 余额,加入官方群再送 $10 余额,合计最高可领 $12 免费额度。\",\n \"channel.preset.unity2.plan.anthropic.label\": \"Anthropic 兼容\",\n \"channel.preset.unity2.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.unity2.plan.openai-chat.label\": \"OpenAI 兼容\",\n \"channel.preset.unity2.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.kimi.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.kimi.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.kimi.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.kimi.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.kimi.plan.coding-anthropic.label\": \"Coding Plan (Anthropic)\",\n \"channel.preset.kimi.plan.coding-anthropic.description\": \"Coding Plan Claude Messages 原生入口\",\n \"channel.preset.kimi.plan.coding-openai-chat.label\": \"Coding Plan (OpenAI)\",\n \"channel.preset.kimi.plan.coding-openai-chat.description\": \"Coding Plan Chat / Responses 通用入口\",\n \"channel.preset.glm.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.glm.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.glm.plan.coding.label\": \"Coding Plan (OpenAI)\",\n \"channel.preset.glm.plan.coding.description\": \"Coding Plan Chat / Responses 通用入口\",\n \"channel.preset.glm.plan.openai-chat.label\": \"通用 (OpenAI)\",\n \"channel.preset.glm.plan.openai-chat.description\": \"通用 Chat / Responses 入口\",\n \"channel.preset.sensenova.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.sensenova.plan.anthropic.description\": \"Claude Messages 兼容入口\",\n \"channel.preset.sensenova.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.sensenova.plan.openai-chat.description\": \"Chat 入口,Responses 由 CCX 转换\",\n \"channel.preset.minimax.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.minimax.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.minimax.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.minimax.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.dashscope.plan.anthropic.label\": \"按量 (Anthropic)\",\n \"channel.preset.dashscope.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.dashscope.plan.openai-chat.label\": \"按量 (OpenAI)\",\n \"channel.preset.dashscope.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.dashscope.plan.coding-anthropic.label\": \"Coding Plan (Anthropic)\",\n \"channel.preset.dashscope.plan.coding-anthropic.description\": \"Coding Plan Claude Messages 原生入口\",\n \"channel.preset.tencent-lkeap.label\": \"腾讯云 TokenHub\",\n \"channel.preset.tencent-lkeap.description\": \"腾讯云大模型服务平台,整合混元及 DeepSeek、Kimi 等主流模型,提供 Token Plan 订阅和按量计费,适配 Claude Code、Codex 等主流编程工具。\",\n \"channel.preset.tencent-lkeap.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.tencent-lkeap.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.tencent-lkeap.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.tencent-lkeap.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.kimi-code.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.kimi-code.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.kimi-code.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.kimi-code.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.volc-ark.label\": \"火山方舟 Coding Plan\",\n \"channel.preset.volc-ark.description\": \"字节跳动火山引擎旗下一站式 AI 平台,提供豆包系列模型精调、推理、评测全链路服务,覆盖多厂商模型,企业级保障完善。\",\n \"channel.preset.volc-ark.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.volc-ark.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.volc-ark.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.volc-ark.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.qianfan.label\": \"百度千帆 Coding Plan\",\n \"channel.preset.qianfan.description\": \"百度智能云企业级大模型平台,内置文心 5.0 原生全模态大模型,支持多智能体协同,提供从开发到部署的全链路工具。\",\n \"channel.preset.qianfan.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.qianfan.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.qianfan.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.qianfan.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.originrouter.label\": \"极易云 OriginRouter\",\n \"channel.preset.originrouter.description\": \"极易云统一模型 API 转发平台,一个密钥无缝切换 GPT、Claude、Gemini 等主流模型,高速稳定,适合多模型混用场景。\",\n \"channel.preset.originrouter.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.originrouter.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.originrouter.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.originrouter.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.dashscope.plan.coding-openai-chat.label\": \"Coding Plan (OpenAI)\",\n \"channel.preset.dashscope.plan.coding-openai-chat.description\": \"Coding Plan Chat / Responses 通用入口\",\n \"channel.preset.dashscope.plan.token-plan-anthropic.label\": \"Token Plan (Anthropic)\",\n \"channel.preset.dashscope.plan.token-plan-anthropic.description\": \"Token Plan Claude Messages 原生入口\",\n \"channel.preset.dashscope.plan.token-plan-openai-chat.label\": \"Token Plan (OpenAI)\",\n \"channel.preset.dashscope.plan.token-plan-openai-chat.description\": \"Token Plan Chat / Responses 通用入口\",\n \"channel.preset.opencode-zen.plan.go-openai-chat.label\": \"Go 订阅套餐\",\n \"channel.preset.opencode-zen.plan.go-openai-chat.description\": \"低成本订阅入口,Messages 经 CCX 转 Chat 上游,Chat / Responses 直接使用该入口\",\n \"channel.preset.opencode-zen.plan.openai-chat.label\": \"Zen 按量套餐\",\n \"channel.preset.opencode-zen.plan.openai-chat.description\": \"按量付费入口,Messages 经 CCX 转 Chat 上游,Chat / Responses 直接使用该入口\",\n \"channel.preset.opencode-go.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.opencode-go.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.opencode-go.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.opencode-go.plan.openai-chat.description\": \"Messages 经 CCX 转 Chat 上游,Chat / Responses 直接使用该入口\",\n \"channel.preset.modelscope.label\": \"ModelScope\",\n \"channel.preset.modelscope.description\": \"阿里达摩院开源模型社区,涵盖文本、图像、语音等领域 10,000+ 模型,提供模型探索、推理与训练全链路支持。\",\n \"channel.preset.modelscope.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.modelscope.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.modelscope.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.modelscope.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.preset.xfyun.label\": \"讯飞星辰\",\n \"channel.preset.xfyun.description\": \"科大讯飞星辰 MaaS Astron Coding Plan,提供 OpenAI、Anthropic 与 Responses 三个独立 Coding Plan 入口。\",\n \"channel.preset.xfyun.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.xfyun.plan.anthropic.description\": \"Claude Messages Coding Plan 入口\",\n \"channel.preset.xfyun.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.xfyun.plan.openai-chat.description\": \"OpenAI Chat Coding Plan 入口\",\n \"channel.preset.xfyun.plan.responses.label\": \"Responses-compatible\",\n \"channel.preset.xfyun.plan.responses.description\": \"OpenAI Responses Coding Plan 入口\",\n \"channel.preset.openrouter.label\": \"OpenRouter\",\n \"channel.preset.openrouter.description\": \"最大规模 AI 模型聚合平台,单个 API Key 即可访问数百款模型。OpenAI 兼容协议,内置安全治理与成本控制能力。\",\n \"channel.preset.openrouter.plan.anthropic.label\": \"Anthropic-compatible\",\n \"channel.preset.openrouter.plan.anthropic.description\": \"Claude Messages 原生入口\",\n \"channel.preset.openrouter.plan.openai-chat.label\": \"OpenAI-compatible\",\n \"channel.preset.openrouter.plan.openai-chat.description\": \"Chat / Responses 通用入口\",\n \"channel.target.messages.label\": \"Claude Messages\",\n \"channel.target.messages.description\": \"Claude 原生 Messages 协议,支持 Claude Code 直连或通过 CCX 代理\",\n \"channel.target.responses.label\": \"Codex Responses\",\n \"channel.target.responses.description\": \"OpenAI Responses 协议,专供 Codex CLI 及兼容客户端使用\",\n \"channel.target.chat.label\": \"OpenAI Chat\",\n \"channel.target.chat.description\": \"OpenAI Chat Completions 协议,兼容各类 Chat 客户端和第三方工具\",\n \"env.title\": \"环境配置\",\n \"env.pathDetecting\": \"检测中\",\n \"env.refresh\": \"刷新\",\n \"env.openingEditor\": \"打开中…\",\n \"env.openWithEditor\": \"用 {editor} 打开\",\n \"env.openInEditor\": \"用编辑器打开\",\n \"env.selectEditor\": \"选择编辑器…\",\n \"env.save\": \"保存\",\n \"env.saving\": \"保存中\",\n \"env.saved\": \"已保存\",\n \"env.failed\": \"失败\",\n \"env.hide\": \"隐藏\",\n \"env.show\": \"显示\",\n \"env.copied\": \"已复制\",\n \"env.copy\": \"复制\",\n \"env.fieldRequired\": \"{field}不能为空\",\n \"env.fieldDisallow\": \"{field}不能使用示例占位值\",\n \"env.fieldNumber\": \"{field}必须是数字\",\n \"env.fieldInteger\": \"{field}必须是整数\",\n \"env.fieldMin\": \"{field}不能小于 {min}\",\n \"env.fieldMax\": \"{field}不能大于 {max}\",\n \"env.loadFailed\": \"加载配置失败:{error}\",\n \"env.saveSuccess\": \".env 已保存,重启服务后生效\",\n \"agent.statusDetecting\": \"检测中\",\n \"agent.statusConfigured\": \"已配置\",\n \"agent.statusPortMismatch\": \"端口不匹配\",\n \"agent.statusUnconfigured\": \"未配置\",\n \"agent.localGateway\": \"当前 CCX 网关\",\n \"agent.custom\": \"自定义\",\n \"agent.currentProvider\": \"当前 Provider\",\n \"agent.currentUrl\": \"当前 URL\",\n \"agent.targetUrl\": \"目标 URL\",\n \"agent.notSet\": \"未设置\",\n \"agent.configPath\": \"配置文件\",\n \"agent.authPath\": \"认证文件\",\n \"agent.openFileInEditor\": \"用编辑器打开\",\n \"agent.applyConfig\": \"应用配置\",\n \"agent.restoreConfig\": \"恢复原始配置\",\n \"agent.migrateSessions\": \"迁移会话\",\n \"agent.migrateConfirmTitle\": \"迁移 Codex 会话\",\n \"agent.migrateConfirmDesc\": \"将 Codex 本地会话元数据改写为当前 provider,覆盖历史会话、归档会话和 Codex 状态数据库,但不会删除任何会话。\",\n \"agent.migrateTarget\": \"目标 model_provider\",\n \"agent.migrateRunning\": \"正在迁移会话...\",\n \"agent.migrateConfirm\": \"确认迁移\",\n \"agent.migrateClose\": \"关闭\",\n \"agent.migrateResultSuccess\": \"迁移完成:{migrated}/{total} 个会话文件已更新\",\n \"agent.migrateResultSkippedLabel\": \"跳过文件\",\n \"agent.migrateResultFailedLabel\": \"失败文件\",\n \"agent.migrateSQLiteUpdated\": \"SQLite 数据库已更新:{count} 行\",\n \"agent.migrateSQLiteSkipped\": \"SQLite 数据库已跳过\",\n \"agent.openConsole\": \"访问官方控制台\",\n \"agent.codexPlaceholderSaved\": \"已保存,留空则使用已保存的 key\",\n \"agent.codexPlaceholderRequired\": \"必填:输入 API Key\",\n \"agent.codexPlaceholderWriteOnly\": \"仅写入 Codex 配置\",\n \"agent.diffPreviewApply\": \"应用配置预览\",\n \"agent.diffPreviewRestore\": \"恢复配置预览\",\n \"agent.diffConfirmApply\": \"确认应用\",\n \"agent.diffConfirmRestore\": \"确认恢复\",\n \"agent.diffActionCreate\": \"创建\",\n \"agent.diffActionDelete\": \"删除\",\n \"agent.diffActionModify\": \"修改\",\n \"agent.diffComputing\": \"计算变更中...\",\n \"agent.diffNoChanges\": \"无变更\",\n \"agent.diffExpandContext\": \"展开 {count} 行未变更内容\",\n \"agent.diffCollapseContext\": \"收起 {count} 行\",\n \"agent.diffCancel\": \"取消\",\n \"agent.codexMode\": \"模式\",\n \"agent.codexQuickMode\": \"快捷模式\",\n \"agent.codexPluginMode\": \"插件模式\",\n \"agent.codexQuickModeHint\": \"保留 OpenAI 对话历史。使用 OpenAI provider + 目标 API 端点。\",\n \"agent.codexPluginModeHint\": \"启用 Codex 插件功能。切换后之前的 OpenAI 对话可能不可见,切回快捷模式即可访问。\",\n \"agent.sessionMigrationWarning\": \"切换 Codex 模式后,已有会话可能不可见。会话不会被删除 — 切回之前的模式即可访问。\",\n \"agent.provider.localGateway\": \"CCX 本地网关\",\n \"agent.provider.deepseekDirect\": \"DeepSeek 直连\",\n \"agent.provider.mimoDirect\": \"MiMo 直连\",\n \"agent.provider.compshareDirect\": \"Compshare 直连\",\n \"agent.provider.runapiDirect\": \"RunAPI 直连\",\n \"agent.provider.unity2Direct\": \"Unity2.ai 直连\",\n \"agent.provider.kimiDirect\": \"Kimi 直连\",\n \"agent.provider.glmDirect\": \"GLM 直连\",\n \"agent.provider.sensenovaDirect\": \"SenseNova 直连\",\n \"agent.provider.minimaxDirect\": \"MiniMax 直连\",\n \"agent.provider.dashscopeDirect\": \"DashScope 直连\",\n \"agent.provider.opencodeZenDirect\": \"OpenCode Zen 直连\",\n \"agent.provider.opencodeGoDirect\": \"OpenCode Go 直连\",\n \"agent.provider.openaiDirect\": \"OpenAI 直连\",\n \"agent.provider.openrouterDirect\": \"OpenRouter 直连\",\n \"agent.provider.modelscopeDirect\": \"ModelScope 直连\",\n \"agent.provider.xfyunDirect\": \"讯飞星辰直连\",\n \"agent.provider.tencentLkeapDirect\": \"腾讯云 TokenHub 直连\",\n \"agent.provider.volcArkDirect\": \"火山方舟直连\",\n \"agent.provider.qianfanDirect\": \"百度千帆直连\",\n \"agent.hasOwnApiKey\": \"我有自己的 API Key\",\n \"agent.promo\": \"通过推广链接注册领取专属福利\",\n \"agent.planPayAsYouGo\": \"按量\",\n \"agent.planChina\": \"订阅套餐 - 中国\",\n \"agent.planSingapore\": \"订阅套餐 - 新加坡\",\n \"agent.planEurope\": \"订阅套餐 - 欧洲\",\n \"agent.planSubscription\": \"订阅套餐\",\n \"agent.billingModeMiMo\": \"MiMo 计费模式\",\n \"agent.billingModeDashScope\": \"DashScope 计费模式\",\n \"agent.placeholderSaved\": \"已保存,留空则使用已保存的 key\",\n \"agent.placeholderMimo\": \"必填:MiMo API Key(tp-xxx 或账号 key)\",\n \"agent.placeholderDashScope\": \"必填:DashScope API Key(sk-xxx 或 sk-sp-xxx)\",\n \"agent.placeholderRequired\": \"必填:输入 API Key\",\n \"agent.codexDiagnosticTitle\": \"Codex 排障诊断\",\n \"agent.codexTroubleshoot\": \"排障检测\",\n \"agent.codexTroubleshooting\": \"检测中...\",\n \"agent.codexDiagnosticHealthy\": \"当前 Codex 配置与 CCX 看起来是一致的。\",\n \"agent.codexDiagnosticMismatch\": \"config.toml 与 auth.json 可能不一致。\",\n \"agent.codexDiagnosticCheck\": \"检查项\",\n \"agent.codexDiagnosticAuthMode\": \"认证模式\",\n \"agent.codexDiagnosticConfigPath\": \"config.toml\",\n \"agent.codexDiagnosticAuthPath\": \"auth.json\",\n \"agent.codexDiagnosticSuggestedApply\": \"建议动作:重新应用 Codex -> CCX 配置。\",\n \"agent.codexDiagnosticSuggestedRestore\": \"如果问题仍在,先恢复原始配置,再重新应用。\",\n \"agent.codexDiagnosticStatusTitle\": \"Codex 排障摘要\",\n \"agent.codexDiagnosticStatusHint\": \"请前往 Agent 页检查 Codex 配置一致性。\",\n \"agent.codexDiagnosticCodeMissingApiKey\": \"config.toml 已指向本地 CCX,但 auth.json 缺少 OPENAI_API_KEY。\",\n \"agent.codexDiagnosticCodeProxyKeyMismatch\": \"Codex 当前保存的代理密钥与 CCX 当前代理密钥不一致。\",\n \"agent.codexDiagnosticCodeAuthModeMismatch\": \"config.toml 与 auth.json 要求的认证模式不一致。\",\n \"agent.codexDiagnosticCodePluginMissingBearer\": \"插件模式缺少 experimental_bearer_token。\",\n \"agent.codexDiagnosticCodeLegacyIncomplete\": \"旧式 ccx provider 配置不完整。\",\n \"agent.codexDiagnosticCodeConfigPolluted\": \"config.toml 可能被其他工具污染,已不符合 CCX 预期。\",\n \"agent.codexDiagnosticCodeAuthUnreadable\": \"auth.json 无法解析,可能已经损坏。\",\n \"agent.codexDiagnosticCodeModeMismatch\": \"当前 Codex 配置是{currentMode}模式,但你选择了{targetMode}模式。请重新应用配置。\",\n \"agent.responsesDiagnosticTitle\": \"Responses 渠道诊断\",\n \"agent.responsesDiagnosticHealthy\": \"Responses 渠道当前看起来可用。\",\n \"agent.responsesDiagnosticNoChannel\": \"CCX 当前没有配置任何 Responses 渠道。\",\n \"agent.responsesDiagnosticAllDisabled\": \"已存在 Responses 渠道,但当前没有任何活跃渠道。\",\n \"agent.responsesDiagnosticNoApiKeys\": \"活跃的 Responses 渠道缺少 API Key。\",\n \"agent.responsesDiagnosticProtocolMismatch\": \"已存在 Responses 渠道,但它们的协议路由看起来并不兼容 /v1/responses。\",\n \"agent.responsesDiagnosticCircuitOpen\": \"所有活跃的 Responses 渠道当前都处于熔断状态。\",\n \"agent.responsesDiagnosticSuggestedRefresh\": \"建议动作:刷新渠道状态和日志。\",\n \"agent.responsesDiagnosticSuggestedCreate\": \"建议动作:为当前 Codex provider 新增一个 Responses 渠道。\",\n \"agent.responsesDiagnosticSuggestedEnable\": \"建议动作:启用或恢复至少一个 Responses 渠道。\",\n \"agent.responsesDiagnosticSuggestedAddKey\": \"建议动作:给活跃的 Responses 渠道补充 API Key。\",\n \"agent.responsesDiagnosticSuggestedProtocolMismatch\": \"建议动作:检查 serviceType 和 baseUrl,确保该渠道最终落到 /responses 端点。\",\n \"agent.responsesDiagnosticSuggestedResume\": \"建议动作:查看日志并在恢复后重新启用相关渠道。\",\n \"agent.responsesLogsDiagnosticTitle\": \"最近失败请求\",\n \"agent.responsesLogsDiagnosticHealthy\": \"未发现最近的 Responses 失败请求。\",\n \"agent.responsesLogsDiagnosticAuth\": \"最近的 Responses 请求出现了认证错误(401/403)。\",\n \"agent.responsesLogsDiagnosticRateLimit\": \"最近的 Responses 请求触发了限流(429)。\",\n \"agent.responsesLogsDiagnosticUpstream5xx\": \"最近的 Responses 请求出现了上游 5xx 错误。\",\n \"agent.responsesLogsDiagnosticTimeout\": \"最近的 Responses 请求看起来发生了超时或卡住。\",\n \"agent.responsesLogsDiagnosticGeneric\": \"最近的 Responses 请求失败了,请查看渠道日志获取细节。\",\n \"agent.responsesLogsDiagnosticSuggestedRefresh\": \"建议动作:刷新最近的渠道日志。\",\n \"agent.responsesLogsDiagnosticSuggestedAuth\": \"建议动作:检查上游 API Key、账户额度和认证设置。\",\n \"agent.responsesLogsDiagnosticSuggestedRateLimit\": \"建议动作:降低请求频率,或等待上游限流窗口恢复。\",\n \"agent.responsesLogsDiagnosticSuggestedUpstream5xx\": \"建议动作:切换渠道、检查上游状态,或稍后再试。\",\n \"agent.responsesLogsDiagnosticSuggestedTimeout\": \"建议动作:检查上游延迟、超时配置和最近日志。\",\n \"agent.responsesLogsDiagnosticSuggestedGeneric\": \"建议动作:打开 Responses 渠道日志并检查最近失败项。\",\n \"agent.codexDiagnosticIntro\": \"按需排障检测:配置一致性 → Responses 渠道可用性 → 最近失败请求。\",\n \"agent.codexDiagnosticLayerConfig\": \"第 1 层 · Codex 配置\",\n \"agent.codexDiagnosticLayerChannels\": \"第 2 层 · Responses 渠道\",\n \"agent.codexDiagnosticLayerLogs\": \"第 3 层 · 最近失败请求\",\n \"env.groupAccess\": \"访问控制\",\n \"env.groupAccessDesc\": \"代理入口与管理入口的访问密钥。\",\n \"env.fieldProxyAccessKey\": \"代理访问密钥\",\n \"env.placeholderProxyAccessKey\": \"请输入强随机密钥\",\n \"env.fieldAdminAccessKey\": \"管理 API 独立密钥\",\n \"env.placeholderAdminAccessKey\": \"留空则回退到 PROXY_ACCESS_KEY\",\n \"env.descAdminAccessKey\": \"用于管理界面和 /api/* 端点。\",\n \"env.groupServer\": \"服务器配置\",\n \"env.groupServerDesc\": \"Desktop 会在启动时注入部分运行参数;这里仍完整覆盖 .env.example。\",\n \"env.fieldPort\": \"服务端口\",\n \"env.descPort\": \"启动时优先使用此端口,被占用时自动递增分配。\",\n \"env.fieldBindHost\": \"监听主机\",\n \"env.descBindHost\": \"留空等价于监听所有网卡(:PORT / 0.0.0.0);填写 127.0.0.1 则只允许本机访问。\",\n \"env.fieldEnableHttps\": \"启用本地 HTTPS\",\n \"env.descEnableHttps\": \"用于 Claude Desktop 等只接受 HTTPS 网关的客户端;开启后 WebURL 和健康检查都会切换为 https。\",\n \"env.fieldTlsAutoCert\": \"自动生成本地证书\",\n \"env.descTlsAutoCert\": \"未提供证书文件时生成临时 localhost 自签名证书;如客户端要求受信任证书,请改用证书文件。\",\n \"env.fieldTlsCertFile\": \"TLS 证书文件\",\n \"env.placeholderTlsCertFile\": \"例如 /Users/me/.config/ccx/localhost.pem\",\n \"env.descTlsCertFile\": \"PEM 格式证书路径;与 TLS_KEY_FILE 同时设置。\",\n \"env.fieldTlsKeyFile\": \"TLS 私钥文件\",\n \"env.placeholderTlsKeyFile\": \"例如 /Users/me/.config/ccx/localhost-key.pem\",\n \"env.descTlsKeyFile\": \"PEM 格式私钥路径;与 TLS_CERT_FILE 同时设置。\",\n \"env.fieldEnv\": \"运行环境\",\n \"env.descEnv\": \"production 为推荐值。\",\n \"env.groupWebUI\": \"Web UI 配置\",\n \"env.groupWebUIDesc\": \"控制管理界面是否启用以及默认语言。\",\n \"env.fieldEnableWebUI\": \"启用 Web UI\",\n \"env.descEnableWebUI\": \"Desktop 模式通常会强制启用。\",\n \"env.fieldAppUILanguage\": \"默认语言\",\n \"env.groupLogs\": \"日志配置\",\n \"env.groupLogsDesc\": \"控制请求/响应日志、SSE 调试和模型字段改写。\",\n \"env.fieldLogLevel\": \"日志级别\",\n \"env.fieldEnableRequestLogs\": \"启用请求日志\",\n \"env.fieldEnableResponseLogs\": \"启用响应日志\",\n \"env.descEnableResponseLogs\": \"响应日志可能增加敏感内容暴露风险。\",\n \"env.fieldQuietPollingLogs\": \"静默轮询日志\",\n \"env.fieldRawLogOutput\": \"原始日志输出\",\n \"env.fieldSseDebugLevel\": \"SSE 调试级别\",\n \"env.fieldRewriteResponseModel\": \"改写响应 model\",\n \"env.groupPerformance\": \"性能配置\",\n \"env.groupPerformanceDesc\": \"请求链路超时和请求体大小限制。\",\n \"env.fieldServerReadTimeout\": \"服务端读取超时(毫秒)\",\n \"env.fieldMaxRequestBodySize\": \"请求体最大大小(MB)\",\n \"env.groupCors\": \"CORS 配置\",\n \"env.groupCorsDesc\": \"跨域访问控制。\",\n \"env.fieldEnableCors\": \"启用 CORS\",\n \"env.fieldCorsOrigin\": \"允许的 Origin\",\n \"env.groupCircuitBreaker\": \"熔断指标配置\",\n \"env.groupCircuitBreakerDesc\": \"控制调度指标窗口与失败率阈值。\",\n \"env.fieldMetricsWindowSize\": \"滑动窗口大小\",\n \"env.fieldMetricsFailureThreshold\": \"失败率阈值\",\n \"env.runtimeCbTitle\": \"调校台\",\n \"env.runtimeCbDesc\": \"精调熔断与流控参数,修改立即生效\",\n \"env.runtimeCbWindowSize\": \"滑动窗口大小\",\n \"env.runtimeCbWindowSizeDesc\": \"计算失败率的最近请求数(3-100)\",\n \"env.runtimeCbFailureThreshold\": \"失败率阈值\",\n \"env.runtimeCbFailureThresholdDesc\": \"超过此值触发熔断(0.01-1.0)\",\n \"env.runtimeCbConsecutiveFailures\": \"连续失败阈值\",\n \"env.runtimeCbConsecutiveFailuresDesc\": \"连续可重试失败次数达此值触发熔断(1-100)\",\n \"env.runtimeCbRequestTimeout\": \"非流式请求超时\",\n \"env.runtimeCbResponseHeaderTimeout\": \"响应头等待超时\",\n \"env.runtimeCbStreamFirstContentTimeout\": \"首字等待超时\",\n \"env.runtimeCbStreamFirstContentTimeoutDesc\": \"HTTP 200 后等待首个有效内容的时间(0=禁用, 1000-300000ms)\",\n \"env.runtimeCbStreamInactivityTimeout\": \"首字后断流超时\",\n \"env.runtimeCbStreamInactivityTimeoutDesc\": \"首字后等待后续活动的时间(0=禁用, 1000-180000ms)\",\n \"env.runtimeCbStreamToolCallIdleTimeout\": \"工具调用空闲超时\",\n \"env.runtimeCbStreamToolCallIdleTimeoutDesc\": \"工具调用 pending 时等待有效输出的时间(1000-180000ms)\",\n \"env.runtimeCbPresetGentle\": \"温和\",\n \"env.runtimeCbPresetBalanced\": \"均衡\",\n \"env.runtimeCbPresetAggressive\": \"激进\",\n \"env.runtimeCbPresetCustom\": \"自定义\",\n \"env.runtimeCbSaved\": \"调校台设置已保存,修改立即生效\",\n \"env.runtimeCbSaveFailed\": \"保存失败:{error}\",\n \"env.runtimeCbLoadFailed\": \"加载失败:{error}\",\n \"env.runtimeCbNoBackend\": \"后端服务未运行,请先启动服务\",\n \"env.runtimeCbServiceStopped\": \"服务已停止,启动后可配置运行时参数\",\n \"env.groupMetricsPersistence\": \"指标持久化配置\",\n \"env.groupMetricsPersistenceDesc\": \"控制 SQLite 指标持久化与数据保留。\",\n \"env.fieldMetricsPersistenceEnabled\": \"启用指标持久化\",\n \"env.fieldMetricsRetentionDays\": \"指标保留天数\",\n \"logs.searchPlaceholder\": \"搜索日志...\",\n \"logs.autoScroll\": \"自动滚动到底部\",\n \"logs.copied\": \"已复制!\",\n \"logs.copyAll\": \"复制全部日志\",\n \"logs.clear\": \"清空日志控制台\",\n \"logs.noSearchResults\": \"未找到匹配的日志行\",\n \"logs.empty\": \"暂无日志输出,启动服务后即可查看\",\n \"diagnostic.binaryTitle\": \"二进制文件未找到\",\n \"diagnostic.binarySuggestionBuild\": \"确认 CCX 二进制已构建: cd backend-go && make build\",\n \"diagnostic.binarySuggestionCheckDataDir\": \"检查 Desktop 数据目录中是否存在 ccx-go / ccx-go.exe\",\n \"diagnostic.binarySuggestionDownload\": \"首次使用需先构建后端,或从 Release 页面下载预编译版本\",\n \"diagnostic.portTitle\": \"端口冲突\",\n \"diagnostic.portSuggestionInstance\": \"检查是否有其他 CCX 实例已在运行\",\n \"diagnostic.portSuggestionEnv\": \"修改 .env 中 PORT 字段使用其他端口\",\n \"diagnostic.portSuggestionInspect\": \"使用 lsof -i :3688 (macOS/Linux) 或 netstat -ano | findstr :3688 (Windows) 检查端口占用\",\n \"diagnostic.healthTitle\": \"健康检查超时\",\n \"diagnostic.healthSuggestionLogs\": \"查看日志面板中是否有启动错误信息\",\n \"diagnostic.healthSuggestionEnv\": \"检查 .env 配置是否有语法错误\",\n \"diagnostic.healthSuggestionChannels\": \"确认上游渠道配置正确,首次启动可能需要较长时间\",\n \"diagnostic.healthSuggestionRestart\": \"尝试手动重启服务\",\n \"diagnostic.permissionTitle\": \"权限不足\",\n \"diagnostic.permissionSuggestionDataDir\": \"检查数据目录是否有写入权限\",\n \"diagnostic.permissionSuggestionExecutable\": \"macOS/Linux: 确认二进制文件有执行权限 (chmod +x)\",\n \"diagnostic.permissionSuggestionWindows\": \"Windows: 尝试以管理员身份运行\",\n \"diagnostic.genericTitle\": \"启动失败\",\n \"diagnostic.genericSuggestionLogs\": \"查看下方日志面板获取详细错误信息\",\n \"diagnostic.genericSuggestionRestart\": \"尝试重启服务\",\n \"setup.errorEmptyKey\": \"PROXY_ACCESS_KEY 不能为空\",\n \"env.saveSuccessHint\": \".env 已保存,重启服务后生效\",\n \"env.openedInEditor\": \"已在编辑器中打开 .env 文件\",\n \"sidebar.versionHintStore\": \"Microsoft Store 版本由 Store 自动更新\",\n \"sidebar.versionHintTray\": \"通过托盘菜单检查更新\",\n \"sidebar.versionClickCheck\": \"点击检查更新\",\n \"sidebar.updateAvailable\": \"新版 {version}\",\n \"sidebar.updateAvailableHint\": \"点击查看发布说明\",\n \"sidebar.updateInstallHint\": \"点击安装更新\",\n \"sidebar.updateOpenReleaseHint\": \"点击查看发布说明\",\n \"sidebar.theme\": \"主题\",\n \"sidebar.themeAuto\": \"跟随系统\",\n \"sidebar.themeLight\": \"亮色\",\n \"sidebar.themeDark\": \"暗色\",\n \"app.tabs.messages\": \"渠道管理\",\n \"app.tabs.conversations\": \"驾驶舱\",\n \"console.nativeChannelsEyebrow\": \"原生渠道编排\",\n \"console.nativeChannelsTitle\": \"WebUI 对齐协议工作区\",\n \"console.quickAdd\": \"快速添加\",\n \"console.quickAddEyebrow\": \"渠道预设\",\n \"console.quickAddTitle\": \"快速添加渠道\",\n \"console.actions.deleting\": \"正在删除...\",\n \"common.refreshing\": \"刷新中\",\n \"common.copy\": \"复制\",\n \"common.copied\": \"已复制\",\n \"common.delete\": \"删除\",\n \"common.add\": \"添加\",\n \"orchestration.searchPlaceholder\": \"搜索渠道...\",\n \"orchestration.noMatchingChannels\": \"没有匹配的渠道\",\n \"app.actions.refresh\": \"刷新\",\n \"app.actions.ping\": \"测试延迟\",\n \"app.actions.addChannel\": \"添加渠道\",\n \"orchestration.noActiveChannels\": \"暂无渠道,点击上方按钮添加\",\n \"orchestration.multiChannel\": \"多渠道模式\",\n \"orchestration.singleChannel\": \"单渠道模式\",\n \"orchestration.failoverSequence\": \"故障转移序列\",\n \"orchestration.standbyPool\": \"备用资源池\",\n \"orchestration.enable\": \"启用\",\n \"channelCard.suspended\": \"暂停\",\n \"channelCard.actions\": \"操作\",\n \"orchestration.moveToPool\": \"移至备用池\",\n \"orchestration.cache\": \"缓存\",\n \"orchestration.cacheWriteHigh\": \"缓存写偏高\",\n \"orchestration.cacheWriteHighHint\": \"15 分钟内缓存写入占比偏高,可能存在缓存命中配置不合理的问题。\",\n \"chart.globalStats\": \"用量统计\",\n \"status.normal\": \"正常\",\n \"status.paused\": \"暂停\",\n \"status.tripped\": \"熔断\",\n \"status.disabled\": \"禁用\",\n \"channelCard.apiKeyManagement\": \"API密钥管理\",\n \"channelCard.configuredKeys\": \"已配置的密钥\",\n \"orchestration.edit\": \"编辑\",\n \"capability.startTest\": \"开始测试\",\n \"orchestration.logs\": \"日志\",\n \"orchestration.copyConfig\": \"复制配置\",\n \"orchestration.openWebsite\": \"打开官网\",\n \"orchestration.pause\": \"暂停\",\n \"orchestration.resume\": \"恢复\",\n \"orchestration.promotion\": \"抢优先级 (5分钟)\",\n \"orchestration.delete\": \"删除\",\n \"orchestration.resumeReset\": \"恢复 (重置指标)\",\n \"orchestration.moveTop\": \"置顶\",\n \"orchestration.moveBottom\": \"置底\",\n \"orchestration.keepOne\": \"(至少保留一个)\",\n \"orchestration.deleteActiveGuard\": \"无法删除:故障转移序列中至少需要保留一个活跃渠道\",\n \"channelCard.disabledKeys\": \"已拉黑密钥\",\n \"channelCard.blacklistReason.authentication_error\": \"认证失败\",\n \"channelCard.blacklistReason.permission_error\": \"权限不足\",\n \"channelCard.blacklistReason.insufficient_balance\": \"余额不足\",\n \"channelCard.blacklistReason.insufficient_quota\": \"额度已耗尽\",\n \"channelCard.blacklistReason.unavailable\": \"不可用\",\n \"channelCard.blacklistReason.rate_limited\": \"频率限制\",\n \"channelCard.blacklistReason.invalid\": \"密钥无效\",\n \"channelCard.blacklistReason.unknown\": \"未知原因\",\n \"channelEditor.title.create\": \"添加渠道\",\n \"channelEditor.title.edit\": \"编辑渠道\",\n \"channelEditor.managed.providerLabel\": \"渠道提供商\",\n \"channelEditor.managed.officialChannel\": \"{provider} 官方渠道\",\n \"channelEditor.managed.officialBadge\": \"官方托管\",\n \"channelEditor.managed.editSubtitle\": \"{provider} 官方渠道 · 管理账号凭证\",\n \"channelEditor.managed.providerChannel\": \"{provider} 托管渠道\",\n \"channelEditor.managed.managedBadge\": \"自动托管\",\n \"channelEditor.managed.providerEditSubtitle\": \"{provider} 托管渠道 · 管理账号凭证\",\n \"channelEditor.managed.customEditSubtitle\": \"自动托管渠道 · 管理名称与账号凭证\",\n \"channelEditor.nav.basic\": \"基础信息\",\n \"channelEditor.basic.name.label\": \"名称\",\n \"channelEditor.basic.name.required\": \"渠道名称必填\",\n \"channelEditor.basic.serviceType.label\": \"上游类型\",\n \"channelEditor.basic.serviceType.placeholder\": \"选择上游类型\",\n \"channelEditor.basic.serviceType.required\": \"请选择上游类型\",\n \"channelEditor.basic.description.label\": \"描述\",\n \"channelEditor.nav.connection\": \"连接\",\n \"channelEditor.basic.baseUrl.label\": \"Base URL\",\n \"channelEditor.basic.baseUrl.required\": \"至少需要一个 Base URL\",\n \"channelEditor.basic.additionalUrls.label\": \"额外 URL(每行一个)\",\n \"channelEditor.transport.proxyUrl.label\": \"代理通道 URL\",\n \"channelEditor.transport.routePrefix.label\": \"接口路由前缀\",\n \"channelEditor.transport.skipTls.label\": \"跳过 TLS 验证\",\n \"channelEditor.nav.auth\": \"认证管理\",\n \"channelEditor.auth.keys.label\": \"API Keys\",\n \"channelEditor.auth.disabledKeys.label\": \"已禁用密钥\",\n \"channelEditor.auth.restoreKey\": \"恢复\",\n \"channelEditor.auth.copyKey\": \"复制密钥\",\n \"channelEditor.auth.deleteKey\": \"删除密钥\",\n \"channelEditor.auth.addKey\": \"添加 Key\",\n \"channelEditor.auth.historicalKeys\": \"历史密钥记录\",\n \"channelEditor.advanced.authHeader.label\": \"认证头\",\n \"channelEditor.advanced.authHeader.hint\": \"覆盖上游 API Key 发送方式;自动模式保持现有兼容逻辑\",\n \"channelEditor.advanced.authHeader.auto\": \"自动\",\n \"addChannel.testCapability\": \"测试能力\",\n \"addChannel.oneClickSetup\": \"一键配置\",\n \"addChannel.commonFilters\": \"常用过滤器\",\n \"addChannel.autoConvertModelNames\": \"自动转换模型名称\",\n \"addChannel.channelName\": \"渠道名称\",\n \"addChannel.apiKeys\": \"API Keys\",\n \"addChannel.detectedKeys\": \"已检测到 {count} 个 API Key\",\n \"addChannel.enterApiKey\": \"请输入至少一个 API Key\",\n \"copilotOAuth.quickAddKeyHint\": \"无需粘贴 API Key,创建后登录 GitHub Copilot 授权\",\n \"addChannel.newChannelPlacementLabel\": \"添加到队列末尾\",\n \"addChannel.expectedRequest\": \"预期请求\",\n \"addChannel.apiKeyLoadBalance\": \"可添加多个密钥作为故障转移备用\",\n \"addChannel.checking\": \"检测中...\",\n \"addChannel.modelsCount\": \"models {statusCode} ({count} 个)\",\n \"addChannel.supportedModelsLabel\": \"支持的模型 (可选)\",\n \"addChannel.supportedModelsPlaceholder\": \"输入规则后按回车添加,如 gpt-4*、*image*、!*image*\",\n \"addChannel.supportedModelsHint\": \"匹配客户端请求的原始模型名(模型重定向发生在渠道选中之后)。留空表示支持所有模型。支持精确匹配、prefix*、*suffix、*contains* 以及 ! 排除规则。\",\n \"addChannel.supportedModelsInvalidPattern\": \"已忽略非法规则。仅支持精确匹配、prefix*、*suffix、*contains* 和 ! 排除规则。\",\n \"addChannel.contextCapabilityTitle\": \"上下文能力\",\n \"addChannel.defaultContextWindowLabel\": \"默认上下文窗口 tokens\",\n \"addChannel.defaultContextWindowHint\": \"当实际模型不在内置库或 JSON 覆盖中时使用;留空则走未知模型策略。\",\n \"addChannel.defaultMaxOutputLabel\": \"默认最大输出 tokens\",\n \"addChannel.defaultMaxOutputHint\": \"仅在请求显式设置输出上限时参与过滤。\",\n \"addChannel.modelCapabilitiesJsonLabel\": \"实际模型能力 JSON\",\n \"addChannel.modelCapabilitiesJsonHint\": \"key 使用模型重定向后的实际模型名,支持通配符;空对象表示不覆盖内置能力。\",\n \"addChannel.modelCapabilitiesJsonInvalid\": \"请输入合法的 JSON 对象,值只能包含 contextWindowTokens、maxOutputTokens、thinkingMode、reasoningEfforts。\",\n \"addChannel.allowUnknownContextLabel\": \"允许未知模型承载大上下文\",\n \"addChannel.allowUnknownContextHint\": \"默认未知能力模型只允许 200K 以内请求;启用后大上下文也可候选。\",\n \"addChannel.visionFallbackLabel\": \"视觉回退模型\",\n \"addChannel.visionFallbackPlaceholder\": \"含图请求时使用的替代模型\",\n \"addChannel.visionFallbackHint\": \"当请求包含图片且目标模型不支持 vision 时,使用此模型替代。留空则 failover 到下一个渠道。\",\n \"addChannel.visionFallbackReasoningLabel\": \"回退思考强度\",\n \"addChannel.visionFallbackReasoningHint\": \"含图请求改用视觉回退模型时使用的思考强度。\",\n \"channelEditor.auth.apiKeyRequired\": \"至少需要一个 API Key\",\n \"channelEditor.mapping.reasoningEffort.label\": \"推理强度\",\n \"channelEditor.compat.noVisionModels\": \"无视觉模型(每行一个)\",\n \"channelEditor.compat.visionFallback.label\": \"视觉回退模型\",\n \"channelEditor.mapping.fetchModels\": \"获取模型列表\",\n \"channelEditor.mapping.fetchingModels\": \"拉取中...\",\n \"channelEditor.mapping.modelFetchNeedsConfig\": \"需要 Base URL 和 API Key 才能获取模型列表\",\n \"channelEditor.compat.visionEnabled\": \"视觉已启用\",\n \"channelEditor.compat.visionDisabled\": \"视觉已禁用\",\n \"channelEditor.mapping.models\": \"模型\",\n \"channelEditor.mapping.configured.label\": \"已配置\",\n \"channelEditor.mapping.supportedModels\": \"支持的模型(每行一个,留空=全部)\",\n \"addChannel.modelHintTip\": \"点击目标模型输入框会自动获取上游支持的模型列表,每个 API Key 的检测状态会显示在密钥列表中\",\n \"addChannel.modelMappingHintChat\": \"配置模型名称映射,将请求中的模型名重定向到目标模型。例如:将 \\\"gpt-4o\\\" 重定向到 \\\"gpt-5.4\\\"\",\n \"addChannel.modelMappingHintGemini\": \"配置模型名称映射,将请求中的模型名重定向到目标模型。例如:将 \\\"gemini-pro\\\" 重定向到 \\\"gemini-3.5-flash\\\"\",\n \"addChannel.modelMappingHintResponses\": \"配置模型名称映射,将请求中的模型名重定向到目标模型。例如:将 \\\"opus\\\" 重定向到 \\\"gpt-5.4\\\"\",\n \"addChannel.modelMappingHintVectors\": \"配置 Embeddings 模型名称映射,将请求中的向量模型名重定向到目标模型。例如:将 \\\"text-embedding-3-small\\\" 重定向到 \\\"jina-embeddings-v2-base-zh\\\"\",\n \"addChannel.modelMappingHintMessages\": \"配置模型名称映射,将请求中的模型名重定向到目标模型。例如:将 \\\"opus\\\" 重定向到 \\\"claude-sonnet-4-5-20250929\\\"\",\n \"addChannel.targetModelPlaceholderChat\": \"例如:gpt-5.4\",\n \"addChannel.targetModelPlaceholderResponses\": \"例如:gpt-5.4\",\n \"addChannel.targetModelPlaceholderGemini\": \"例如:gemini-3.5-flash\",\n \"addChannel.targetModelPlaceholderVectors\": \"例如:text-embedding-3-small\",\n \"addChannel.targetModelPlaceholderMessages\": \"例如:claude-opus-4-6\",\n \"addChannel.sourceModelNameTooLong\": \"自定义模型名过长(最多 50 字符)\",\n \"addChannel.sourceModelNoSpaces\": \"模型名不能包含空格\",\n \"addChannel.sourceModelInvalidChars\": \"模型名只能包含字母、数字、连字符、下划线、点、斜杠、冒号、@ 和 +\",\n \"addChannel.fillBaseUrlAndApiKey\": \"请先填写 Base URL 和至少一个 API Key\",\n \"addChannel.allApiKeysModelsFailed\": \"所有 API Key 都无法获取模型列表,请检查 API 密钥列表中的错误信息\",\n \"channelEditor.compat.title\": \"协议规范化\",\n \"channelEditor.compat.diagnose\": \"诊断\",\n \"channelEditor.compat.diagnoseApplied\": \"已应用 {count} 项推荐配置\",\n \"channelEditor.compat.diagnoseNoChange\": \"当前配置已是最优,无需调整\",\n \"channelEditor.compat.diagnoseFailed\": \"诊断失败,请检查渠道配置\",\n \"channelEditor.compat.diagnoseAppliedShort\": \"已应用 {count} 项\",\n \"channelEditor.compat.diagnoseNoChangeShort\": \"已最优\",\n \"channelEditor.compat.diagnoseFailedShort\": \"失败\",\n \"channelEditor.compat.reasoningStyle.label\": \"Reasoning 参数样式\",\n \"channelEditor.compat.textVerbosity.label\": \"输出冗长度\",\n \"channelEditor.compat.flags\": \"高级选项\",\n \"channelEditor.compat.noVision.label\": \"跳过含图请求\",\n \"channelEditor.compat.fastMode.label\": \"快速模式\",\n \"channelEditor.transport.lowQuality.label\": \"低质量渠道\",\n \"channelEditor.compat.injectDummySignature.label\": \"注入 Gemini 思考标记\",\n \"channelEditor.compat.stripThoughtSignature.label\": \"剥离 Gemini 思考标记\",\n \"channelEditor.compat.normalizeSystem.label\": \"规范化 System 角色域\",\n \"channelEditor.compat.normalizeUserId.label\": \"规范化用户 ID\",\n \"channelEditor.compat.stripBillingHeader.label\": \"剔除 CCH 计费尾缀\",\n \"channelEditor.runtime.autoBlacklist.label\": \"自动熔断/黑名单余额异常 Key\",\n \"channelEditor.compat.codexCompat.label\": \"Codex 工具兼容模式\",\n \"channelEditor.compat.stripCodexTools.label\": \"移除 Codex 客户端工具\",\n \"channelEditor.nav.custom\": \"自定义参数\",\n \"channelEditor.transport.requestTimeout.label\": \"请求超时 (s)\",\n \"channelEditor.transport.requestTimeout.invalid\": \"请求超时必须是 1-300 秒之间的整数\",\n \"channelEditor.transport.responseHeaderTimeout.label\": \"响应头等待超时 (s)\",\n \"channelEditor.transport.responseHeaderTimeout.invalid\": \"响应头等待超时必须是 1-300 秒之间的整数\",\n \"channelEditor.streamTimeout.title\": \"流式断流超时控制\",\n \"channelEditor.streamTimeout.firstContent.overrideLabel\": \"自定义首字等待超时\",\n \"channelEditor.streamTimeout.firstContent.label\": \"首字等待\",\n \"channelEditor.streamTimeout.inactivity.overrideLabel\": \"自定义断流超时\",\n \"channelEditor.streamTimeout.inactivity.label\": \"首字后断流\",\n \"channelEditor.streamTimeout.toolCallIdle.overrideLabel\": \"自定义工具调用空闲超时\",\n \"channelEditor.streamTimeout.toolCallIdle.label\": \"工具调用空闲\",\n \"channelEditor.streamTimeout.overrideHint\": \"自定义值将覆盖此渠道的全局流式超时设置\",\n \"channelEditor.streamTimeout.inheritHint\": \"禁用表示此渠道继承全局流式超时设置\",\n \"channelEditor.actions.cancel\": \"取消\",\n \"channelEditor.actions.create\": \"创建\",\n \"channelEditor.actions.save\": \"保存\",\n \"channelEditor.error.channelNotFound\": \"未找到渠道配置\",\n \"channelEditor.basic.website.label\": \"网站\",\n \"channelEditor.basic.baseUrl.placeholder\": \"每行一个,第一行为主地址,其余作为故障转移\\nhttps://api.example.com\\nhttps://backup.example.com\",\n \"channelEditor.mapping.redirect.title\": \"模型重定向\",\n \"channelEditor.nav.advanced\": \"高级选项\",\n \"channelEditor.nav.outline\": \"配置大纲\",\n \"channelEditor.runtime.title\": \"运行期策略\",\n \"channelEditor.transport.title\": \"代理路由网络\",\n \"channelEditor.rateLimit.title\": \"上游主动限速流量阀\",\n \"channelEditor.compat.fastMode.hint\": \"Codex /fast mode:在支持的模型上提升生成速度,适合更快编码迭代;会以更高倍率消耗额度或使用限制\",\n \"channelEditor.compat.textVerbosity.style\": \"文本详细程度样式\",\n \"channelEditor.compat.vision.title\": \"视觉控制\",\n \"channelEditor.compat.selectDefault\": \"默认\",\n \"channelEditor.compat.autoBlacklistBalance.label\": \"自动熔断/黑名单余额异常 Key\",\n \"channelEditor.compat.autoBlacklistBalance.hint\": \"自动禁用余额异常的 Key\",\n \"channelEditor.runtime.autoLearnRateLimits.label\": \"自动学习上游限速\",\n \"channelEditor.runtime.autoLearnRateLimits.hint\": \"解析 Retry-After / x-ratelimit-* 响应头动态调整 cooldown\",\n \"channelEditor.rateLimit.rpm.label\": \"每分钟最大请求量 (RPM)\",\n \"channelEditor.rateLimit.rpm.placeholder\": \"不设限制\",\n \"channelEditor.rateLimit.window.label\": \"检测窗口滑动时间 (s)\",\n \"channelEditor.rateLimit.window.placeholder\": \"60 秒\",\n \"channelEditor.rateLimit.maxConcurrent.label\": \"全双工最大并发数限制\",\n \"channelEditor.rateLimit.maxConcurrent.placeholder\": \"不设限制\",\n \"channelEditor.streamTimeout.preset.gentle\": \"宽松\",\n \"channelEditor.streamTimeout.preset.balanced\": \"平衡\",\n \"channelEditor.streamTimeout.preset.aggressive\": \"激进\",\n \"channelEditor.streamTimeout.firstContent.hint\": \"未响应则自动断开\",\n \"channelEditor.streamTimeout.inactivity.hint\": \"生成中途卡顿超时\",\n \"channelEditor.streamTimeout.toolCallIdle.hint\": \"FunctionCall 延迟超时\",\n \"channelEditor.streamTimeout.timeoutThreshold\": \"超时阈值\",\n \"channelEditor.mapping.supportedModels.label\": \"限定可支持模型范围(白名单模式,留空表示不限制)\",\n \"channelEditor.mapping.source.duplicate\": \"该源模型已存在映射\",\n \"channelEditor.basic.info.title\": \"基础信息\",\n \"channelEditor.connection.title\": \"连接终点\",\n \"channelEditor.basic.multiLineFailover\": \"多行实现故障轮换\",\n \"channelEditor.basic.expectedEndpoint\": \"预期终点:\",\n \"channelEditor.basic.description.placeholder\": \"可选填,用于备注此节点供应商...\",\n \"channelEditor.mapping.hint\": \"拦截调用请求中的 Source 别名并定向投递至上游 Target 真实模型\",\n \"channelEditor.compat.visionFallback.hint\": \"已通过模型重定向行的视觉开关标记禁用视觉模型;这些模型遇到图像输入时会自动切换到此模型处理\",\n \"channelEditor.mapping.addRedirect\": \"添加新重定向\",\n \"channelEditor.compat.generationParams\": \"生成参数\",\n \"channelEditor.compat.textVerbosity.placeholder\": \"默认\",\n \"channelEditor.mapping.modelScope\": \"模型范围\",\n \"channelEditor.compat.noVision.hint\": \"启用后,包含图片的请求将跳过此渠道并 failover 到下一个渠道\",\n \"channelEditor.compat.historicalImageLimit.label\": \"历史图片轮次限制\",\n \"channelEditor.compat.historicalImageLimit.hint\": \"0 表示不限制;填写 2-10 时,超过限制的历史图片会被替换为占位符\",\n \"channelEditor.compat.compactModel.label\": \"Compact 专用模型\",\n \"channelEditor.compat.compactModel.hint\": \"本地 compact(压缩摘要)时使用的上游模型名,不经过模型映射。对非 Responses 协议渠道生效;对 Responses 渠道,在原生 compact 失败时回退使用。\",\n \"channelEditor.compat.stripThoughtSignature.hint\": \"移除 functionCall 中的 thought_signature 字段\",\n \"channelEditor.compat.injectDummySignature.hint\": \"为第三方 API 注入假签名\",\n \"channelEditor.compat.reasoningStyle.hint\": \"选择 OpenAI 风格请求使用 reasoning.effort 还是 reasoning_effort\",\n \"channelEditor.compat.codexCompat.hint\": \"启用 Codex CLI 兼容模式\",\n \"channelEditor.compat.normalizeSystem.hint\": \"将 messages 数组里的 system 消息抽取回顶层 system 字段。国产模型官方直连(DeepSeek、Kimi、GLM、MiniMax 等)多仅支持 user/assistant,通常需要开启;标准 Anthropic 端点或完整中转站一般无需开启。建议先点「诊断」自动检测。\",\n \"channelEditor.compat.normalizeUserId.hint\": \"扁平化 metadata 中的 user ID\",\n \"channelEditor.compat.stripBillingHeader.hint\": \"移除 CCH 计费后缀(非官方域名默认开启)\",\n \"channelEditor.transport.lowQuality.hint\": \"标记此渠道为低质量,作为最后手段\",\n \"channelEditor.runtime.autoBlacklist.hint\": \"自动禁用余额异常的 Key\",\n \"channelEditor.transport.skipTls.hint\": \"禁用上游 TLS 证书验证\",\n \"channelEditor.transport.proxyUrl.hint\": \"代理上游请求的通道\",\n \"channelEditor.transport.routePrefix.hint\": \"接口路由前缀\",\n \"channelEditor.transport.requestTimeout.hint\": \"非流式上游请求总超时;留空继承全局\",\n \"channelEditor.transport.responseHeaderTimeout.hint\": \"连接后等待上游 HTTP 响应头;慢启动渠道可单独放宽\",\n \"channelEditor.rateLimit.section.label\": \"主动限速\",\n \"channelEditor.rateLimit.section.hint\": \"在请求发往上游前主动限流,避免触发上游 429。\",\n \"channelEditor.rateLimit.rpm.hint\": \"每分钟请求数限制\",\n \"channelEditor.rateLimit.burst.label\": \"突发容量\",\n \"channelEditor.rateLimit.burst.hint\": \"令牌桶容量;留空时自动取每分钟请求数。\",\n \"channelEditor.rateLimit.maxConcurrent.hint\": \"全双工并发流限制\",\n \"addChannel.duplicateKey\": \"重复密钥\",\n \"addChannel.addNewApiKeyPlaceholder\": \"输入完整的API密钥\",\n \"addChannel.customHeadersLabel\": \"自定义请求头 (可选)\",\n \"addChannel.customHeadersHint\": \"添加或覆盖发送到上游的 HTTP 请求头\",\n \"addChannel.streamFirstContentTimeoutLabel\": \"首字等待超时\",\n \"addChannel.streamInactivityTimeoutLabel\": \"首字后断流超时\",\n \"addChannel.streamToolCallIdleTimeoutLabel\": \"工具调用空闲超时\",\n \"addChannel.streamTimeoutStrategyLabel\": \"流式超时策略\",\n \"addChannel.streamTimeoutStrategyInherit\": \"继承全局\",\n \"addChannel.streamTimeoutOverrideHint\": \"自定义值会覆盖本渠道的全局流式超时。\",\n \"addChannel.streamTimeoutInheritHint\": \"关闭表示本渠道继承全局流式超时。\",\n \"addChannel.headerNameLabel\": \"Header 名称\",\n \"addChannel.headerValueLabel\": \"请求头值\",\n \"addChannel.descriptionLabel\": \"描述 (可选)\",\n \"addChannel.descriptionHint\": \"可选的渠道描述...\",\n \"addChannel.skipTlsLabel\": \"跳过 TLS 证书验证\",\n \"addChannel.skipTlsHint\": \"仅在自签名或域名不匹配时临时启用,生产环境请关闭\",\n \"addChannel.lowQualityLabel\": \"低质量渠道\",\n \"addChannel.lowQualityHint\": \"启用后强制本地估算 token 数量,偏差超过 5% 时使用本地值\",\n \"addChannel.quickMode\": \"快速粘贴\",\n \"addChannel.quickHint\": \"粘贴 Base URL、API Key 或完整配置片段,自动识别并填入表单。\",\n \"addChannel.applyDetected\": \"应用识别结果\",\n \"addChannel.serviceTypeManual\": \"手动选择\",\n \"addChannel.serviceTypeDetected\": \"已自动识别\",\n \"addChannel.serviceTypeDefault\": \"使用默认类型\",\n \"addChannel.serviceTypeRecommendedSuffix\": \" · 推荐\",\n \"addChannel.noneDetected\": \"未识别\",\n \"addChannel.fullSubtitle\": \"配置渠道、密钥、模型与高级协议选项\",\n \"addChannel.editSubtitle\": \"修改渠道配置信息\",\n \"cockpit.searchPlaceholder\": \"搜索会话...\",\n \"cockpit.allKinds\": \"所有类型\",\n \"cockpit.refresh\": \"刷新\",\n \"cockpit.total\": \"共 {count} 个会话\",\n \"cockpit.empty\": \"暂无活跃会话。请求经过网关后,会话会出现在驾驶舱雷达上。\",\n \"cockpit.noMatches\": \"没有匹配的会话\",\n \"cockpit.overrideTitle\": \"会话渠道覆盖\",\n \"cockpit.overrideHint\": \"选择该会话使用的渠道序列(按优先级排列):\",\n \"cockpit.overrideSequence\": \"覆盖序列\",\n \"cockpit.overrideRequired\": \"请至少选择一个渠道\",\n \"cockpit.saveOverride\": \"保存覆盖\",\n \"cockpit.copyRawUserId\": \"复制对话 ID\",\n \"cockpit.rawUserIdCopied\": \"对话 ID 已复制\",\n \"cockpit.rawUserIdCopyFailed\": \"复制失败\",\n \"cockpit.tooltip.statusStreaming\": \"状态:正在流式响应\",\n \"cockpit.tooltip.statusActive\": \"状态:处理中\",\n \"cockpit.tooltip.statusIdle\": \"状态:空闲\",\n \"cockpit.tooltip.statusUnknown\": \"状态:未知\",\n \"cockpit.tooltip.kind\": \"请求类型:{kind}\",\n \"cockpit.tooltip.requests\": \"请求轮次:{count}\",\n \"cockpit.tooltip.duration\": \"持续时间:{duration}\",\n \"cockpit.tooltip.subagents\": \"子代理数量:{count}\",\n \"cockpit.tooltip.parentConversation\": \"跳转到主对话:{id}\",\n \"cockpit.tooltip.parentThread\": \"父线程:{id}\",\n \"cockpit.tooltip.childConversation\": \"跳转到子代理:{id}\",\n \"cockpit.tooltip.hiddenChannels\": \"展开查看剩余 {count} 个渠道\",\n \"cockpit.tooltip.restoreDefault\": \"恢复默认渠道顺序\",\n \"cockpit.tooltip.moreSubagents\": \"展开查看剩余 {count} 个子代理\",\n \"cockpit.tooltip.clearSubagentOverride\": \"清除子代理渠道指定\",\n \"cockpit.tooltip.quickCurrentChannel\": \"点击固定当前渠道\",\n \"cockpit.tooltip.quickNextOverride\": \"下一次覆盖目标\",\n \"cockpit.tooltip.quickSetNext\": \"固定为此对话优先渠道\",\n \"cockpit.tooltip.quickResumeAndSetNext\": \"恢复并固定此渠道\",\n \"cockpit.tooltip.quickPinned\": \"已固定为此对话优先渠道,再次点击取消固定\",\n \"cockpit.tooltip.currentChannel\": \"当前正在使用的渠道\",\n \"cockpit.tooltip.nextChannel\": \"下一次将使用的覆盖目标\",\n \"cockpit.tooltip.nextChannelTripped\": \"下一目标已熔断\",\n \"cockpit.tooltip.pausedChannel\": \"渠道已暂停,不参与调度\",\n \"cockpit.tooltip.circuitOpen\": \"熔断已打开,暂时跳过该渠道\",\n \"cockpit.tooltip.channelAlreadyFirst\": \"{name} 已在渠道顺序最前\",\n \"cockpit.tooltip.moveChannelToTop\": \"将 {name} 移到渠道顺序最前\",\n \"cockpit.tooltip.demoteChannel\": \"将 {name} 移到渠道顺序末尾\",\n \"cockpit.tooltip.demoteChannelDisabled\": \"已经是最后一个渠道\",\n \"cockpit.tooltip.columnCount\": \"{column}:{count} 个会话\",\n \"cockpit.overrideActivePerpetual\": \"正在使用自定义渠道顺序(手动恢复前不会自动过期)\",\n \"cockpit.overrideActive\": \"正在使用自定义渠道顺序,{time} 后自动恢复默认调度\",\n \"cockpit.restoreDefault\": \"恢复默认顺序\",\n \"cockpit.overrideDuration\": \"空闲自动恢复\",\n \"cockpit.noChannelsForKind\": \"该类型暂无可用渠道\",\n \"cockpit.board.streaming\": \"流式中\",\n \"cockpit.board.streamingHint\": \"正在流式输出的会话\",\n \"cockpit.board.working\": \"工作中\",\n \"cockpit.board.workingHint\": \"存在活跃工作的主对话\",\n \"cockpit.board.subagents\": \"子代理\",\n \"cockpit.board.subagentsHint\": \"包含子代理的会话\",\n \"cockpit.board.active\": \"活跃\",\n \"cockpit.board.activeHint\": \"仍在活跃但未流式输出的会话\",\n \"cockpit.board.idle\": \"空闲\",\n \"cockpit.board.idleHint\": \"当前空闲的会话\",\n \"cockpit.relation.parent\": \"主对话\",\n \"cockpit.relation.parentThread\": \"父线程 {id}\",\n \"cockpit.relation.children\": \"子代理 {count}\",\n \"cockpit.active\": \"活跃: {count}\",\n \"cockpit.override\": \"覆盖: {count}\",\n \"cockpit.mainConversation\": \"主对话\",\n \"cockpit.recap\": \"Recap\",\n \"cockpit.detail.requests\": \"请求轮次\",\n \"cockpit.detail.duration\": \"持续时间\",\n \"cockpit.mainRouting\": \"主对话渠道\",\n \"cockpit.subagentRouting\": \"子代理渠道\",\n \"cockpit.durationDefault\": \"30 分钟(默认)\",\n \"cockpit.duration15min\": \"15 分钟\",\n \"cockpit.duration1hour\": \"1 小时\",\n \"cockpit.duration2hours\": \"2 小时\",\n \"cockpit.duration4hours\": \"4 小时\",\n \"cockpit.duration8hours\": \"8 小时\",\n \"cockpit.duration12hours\": \"12 小时\",\n \"cockpit.duration24hours\": \"24 小时\",\n \"cockpit.durationNever\": \"永不恢复\",\n \"cockpit.subagentFollowMain\": \"跟随主对话\",\n \"cockpit.subagentOverride\": \"已指定渠道\",\n \"cockpit.subagentClearOverride\": \"清除\",\n \"capability.title\": \"能力测试 - {channel}\",\n \"capability.cancel\": \"取消测试\",\n \"console.capability.running\": \"测试进行中...\",\n \"capability.noResults\": \"尚未进行能力测试\",\n \"console.capability.lastResults\": \"上次测试结果\",\n \"capability.modelsLabel\": \"模型:\",\n \"capability.duration\": \"总耗时\",\n \"console.capability.protocolResults\": \"协议结果\",\n \"console.capability.compatible\": \"兼容协议\",\n \"capability.loadingTitle\": \"正在测试协议兼容性...\",\n \"capability.cancelling\": \"取消中...\",\n \"capability.cancelled\": \"已取消\",\n \"capability.partial\": \"部分可用\",\n \"capability.noCompatibleProtocols\": \"无兼容协议\",\n \"capability.modelQueued\": \"排队中\",\n \"capability.protocolRunning\": \"测试中\",\n \"capability.rpmLabel\": \"测试 RPM\",\n \"capability.progressSummary\": \"{done}/{total} 模型已完成\",\n \"capability.supported\": \"支持\",\n \"capability.unsupported\": \"不支持\",\n \"capability.table.protocol\": \"协议\",\n \"capability.table.status\": \"状态\",\n \"capability.table.successCount\": \"成功数/总数\",\n \"capability.table.latency\": \"延迟\",\n \"capability.table.streaming\": \"流式\",\n \"capability.table.actions\": \"操作\",\n \"capability.copyToTab\": \"复制到此 Tab\",\n \"capability.currentTab\": \"当前 Tab\",\n \"capability.convert\": \"{protocol} 转换\",\n \"capability.modelDetailsUnavailable\": \"模型详情暂不可用\",\n \"capability.tooltipLatency\": \"延迟\",\n \"capability.tooltipStreaming\": \"流式\",\n \"capability.modelStatus\": \"状态\",\n \"capability.actualModel\": \"实际模型\",\n \"capability.codexImageGeneration\": \"Codex 生图工具\",\n \"capability.codexImageGenerationSummary\": \"{supported}/{total} 个 Key 支持\",\n \"capability.codexImageGenerationStripped\": \"已通过剥离工具兼容\",\n \"capability.retryModel\": \"重测此模型\",\n \"capability.testModel\": \"点击测试\",\n \"capability.runModeReusedRunning\": \"复用进行中任务\",\n \"capability.runModeResumedCancelled\": \"恢复已取消任务\",\n \"capability.runModeCacheHit\": \"缓存结果\",\n \"capability.runModeReusedPreviousResults\": \"复用上次成功结果\",\n \"capability.lastResults\": \"上次测试结果\",\n \"capability.models\": \"模型\",\n \"capability.snapshotUpdated\": \"更新时间:{time}\",\n \"channelLogs.title\": \"频道日志\",\n \"channelLogs.status.pending\": \"等待中\",\n \"channelLogs.status.firstByte\": \"首字节\",\n \"channelLogs.status.streaming\": \"流式传输\",\n \"channelLogs.status.completed\": \"已完成\",\n \"channelLogs.status.failed\": \"失败\",\n \"channelLogs.status.cancelled\": \"已取消\",\n \"channelLogs.sourceCapabilityTest\": \"能力测试\",\n \"channelLogs.copyEntry\": \"复制此条日志\",\n \"channelLogs.copiedEntry\": \"已复制日志\",\n \"channelLogs.reasoning.original\": \"原始\",\n \"channelLogs.reasoning.actual\": \"发出\",\n \"channelLogs.retry\": \"重试\",\n \"channelLogs.duration.connect\": \"连接\",\n \"channelLogs.duration.firstByte\": \"首字节\",\n \"channelLogs.duration.total\": \"总耗时\",\n \"console.logs.model\": \"模型\",\n \"console.logs.baseUrl\": \"URL\",\n \"console.logs.key\": \"Key\",\n \"channelLogs.status.connecting\": \"连接中\",\n \"console.logs.duration\": \"耗时\",\n \"console.logs.error\": \"错误\",\n \"console.logs.entries\": \"条\",\n \"channelLogs.empty\": \"暂无日志\",\n \"toast.channelCopied\": \"渠道已复制到 {protocol} Tab\",\n \"toast.copyFailed\": \"复制渠道失败: {message}\",\n \"tooltip.circuitBreakerSettings\": \"调校台\",\n \"chart.close\": \"关闭\",\n \"chart.1h\": \"1h\",\n \"chart.6h\": \"6h\",\n \"chart.24h\": \"24h\",\n \"chart.today\": \"今日\",\n \"chart.7d\": \"1周\",\n \"chart.30d\": \"1月\",\n \"chart.90d\": \"3月\",\n \"chart.180d\": \"半年\",\n \"chart.365d\": \"1年\",\n \"chart.thisyear\": \"今年\",\n \"chart.traffic\": \"流量\",\n \"chart.tokens\": \"Token\",\n \"chart.cost\": \"花费\",\n \"chart.cache\": \"Cache\",\n \"chart.cacheRw\": \"Cache R/W\",\n \"chart.cacheRead\": \"Cache Read\",\n \"chart.cacheWrite\": \"Cache Write\",\n \"chart.totalRequests\": \"总请求数\",\n \"chart.requests\": \"请求数\",\n \"chart.successRate\": \"成功率\",\n \"chart.inputTokens\": \"输入 Token\",\n \"chart.outputTokens\": \"输出 Token\",\n \"chart.input\": \"输入\",\n \"chart.output\": \"输出\",\n \"chart.requestUnit\": \"请求\",\n \"chart.tokenUnit\": \"token\",\n \"chart.issueCount\": \"异常\",\n \"chart.total\": \"合计\",\n \"chart.totalCost\": \"总花费\",\n \"chart.collapse\": \"收起\",\n \"chart.expandModelStats\": \"展开模型统计\",\n \"chart.expandChart\": \"展开图表\",\n \"chart.noData\": \"暂无请求数据\",\n \"chart.noChannelMetrics\": \"暂无 {channel} 的指标数据\",\n \"chart.noModelData\": \"暂无模型统计数据\",\n \"chart.noModelRequestsInRange\": \"选定时间范围内没有模型请求记录\",\n \"chart.noKeyUsageInRange\": \"选定时间范围内没有 Key 使用数据\",\n \"chart.historyLoadFailed\": \"加载历史数据失败\",\n \"chart.modelStatsLoadFailed\": \"加载模型统计失败\",\n \"chart.globalStatsLoadFailed\": \"加载全局统计失败\",\n \"chart.noRequestsInRange\": \"选定时间范围内没有请求记录\",\n \"channelEditor.title.editSubtitle\": \"修改渠道配置\",\n \"channelEditor.nav.redirect\": \"模型重定向\",\n \"channelEditor.basic.name.placeholder\": \"例如 OpenAI 生产环境\",\n \"channelEditor.basic.website.placeholder\": \"https://openai.com\",\n \"channelEditor.auth.duplicateKey\": \"重复的 API Key\",\n \"channelEditor.auth.addNewApiKey.placeholder\": \"输入新的 API Key\",\n \"channelEditor.mapping.source.label\": \"源模型\",\n \"channelEditor.mapping.source.placeholder\": \"gpt-4o\",\n \"channelEditor.mapping.target.label\": \"目标模型\",\n \"channelEditor.mapping.target.placeholder\": \"claude-sonnet-4-6\",\n \"console.form.presets\": \"预设快速注入\",\n \"console.form.existingMappings\": \"已有条目映射\",\n \"console.form.mappings\": \"条\",\n \"console.form.toggleVision\": \"切换视觉通道\",\n \"console.form.visionFallback\": \"视觉回退配置\",\n \"console.form.addNewMapping\": \"添加新映射关系\",\n \"console.form.createMapping\": \"建立映射\",\n \"channelEditor.transport.proxyUrl.placeholder\": \"socks5://...\",\n \"channelEditor.transport.requestTimeout.placeholder\": \"60\",\n \"channelEditor.transport.responseHeaderTimeout.placeholder\": \"60\",\n \"channelEditor.transport.routePrefix.placeholder\": \"kimi\",\n \"channelEditor.rateLimit.window.hint\": \"滑动窗口时长\",\n \"channelEditor.streamTimeout.strategy\": \"策略\",\n \"channelEditor.streamTimeout.strategyInherit\": \"继承全局\",\n \"channelEditor.timeout.title\": \"超时策略\",\n \"channelEditor.timeout.hint\": \"全部以秒为单位;关闭表示继承全局设置\",\n \"channelEditor.toast.updated\": \"渠道已更新\",\n \"channelEditor.toast.added\": \"渠道已添加\",\n \"console.form.existingHeaders\": \"已配置标头\",\n \"console.form.addNewHeader\": \"添加新标头\",\n \"console.form.headerValuePlaceholder\": \"标头携带对应的 Value 内容...\",\n \"console.form.headersNote\": \"说明\",\n \"console.form.headersNoteInject\": \"自定义 Headers 将在每个请求中注入到上游\",\n \"console.form.headersNoteOverride\": \"支持覆盖默认 Headers(如 User-Agent、Authorization 等)\",\n \"console.form.headersNoteTemplate\": \"键名和值都支持模板变量(如果后端实现了变量替换)\",\n \"capability.fetchingModels\": \"正在获取模型列表...\",\n \"capability.selectModels\": \"选择要测试的模型\",\n \"capability.selectAll\": \"全选\",\n \"capability.deselectAll\": \"取消全选\",\n \"capability.noModelsFound\": \"未找到模型,请在下方手动添加\",\n \"capability.addModelLabel\": \"添加模型\",\n \"capability.addModelPlaceholder\": \"例如 claude-sonnet-4-6\",\n \"capability.notStarted\": \"待测试\",\n \"capability.compatibleProtocols\": \"兼容协议\",\n \"capability.modelDetails\": \"模型明细\",\n \"capability.attemptedModels\": \"尝试模型数: {count}\",\n \"capability.startedAt\": \"开始时间\",\n \"capability.testedAt\": \"完成时间\",\n \"capability.success\": \"成功\",\n \"capability.failed\": \"失败\",\n \"capability.failedTooltip\": \"测试失败\",\n \"capability.channelFallback\": \"渠道 #{id}\",\n \"capability.modelRunning\": \"测试中\",\n \"capability.modelSuccess\": \"可用\",\n \"capability.modelFailed\": \"失败\",\n \"capability.modelSkipped\": \"已跳过\",\n \"capability.reasonNotRun\": \"本轮未执行\",\n \"capability.reasonCancelled\": \"完成前已取消\",\n \"capability.reasonTimeout\": \"请求超时\",\n \"capability.noApiKeyError\": \"该渠道未配置 API Key,无法执行能力测试。\",\n \"capability.genericJobError\": \"能力测试失败:{message}\",\n \"capability.redirectTests\": \"模型重定向测试\",\n \"capability.redirectTestsDesc\": \"跨协议模型映射验证\",\n \"addChannel.quickInputPlaceholder\": \"粘贴配置片段,自动识别 Base URL 和 API Key(支持多行)\\nhttps://api.example.com\\nhttps://api-backup.example.com\\nsk-ant-api03-xxxxxxxxxxxxxxxx\\nsk-ant-api03-yyyyyyyyyyyyyyyy\",\n \"addChannel.modelListEmptyHint\": \"暂无模型候选。填写 Base URL 和 API Key 后,聚焦模型输入框会自动从 /models 获取。\",\n \"addChannel.modelCapabilitiesRowsHint\": \"模型重定向的目标模型会自动补到这里并优先套用内置预设;透传或未重定向的模型也可以手动添加。\",\n \"addChannel.modelCapabilityRedirectTargets\": \"重定向目标\",\n \"addChannel.modelCapabilitiesConfigured\": \"已配置模型能力\",\n \"addChannel.modelCapabilityModelLabel\": \"实际模型\",\n \"addChannel.modelCapabilityModelPlaceholder\": \"选择或输入上游实际模型\",\n \"addChannel.contextTokensShort\": \"上下文\",\n \"addChannel.outputTokensShort\": \"输出\",\n \"addChannel.thinkingModeLabel\": \"思考模式\",\n \"addChannel.reasoningEffortsLabel\": \"思考强度\",\n \"addChannel.modelCapabilityBuiltinMatched\": \"预设:{pattern}\",\n \"addChannel.modelCapabilitiesRowsInvalid\": \"请检查模型能力行,模型名不能为空,tokens 和价格必须是非负数。\",\n \"addChannel.inputCacheHitPriceLabel\": \"缓存命中输入价\",\n \"addChannel.inputCacheMissPriceLabel\": \"缓存未命中输入价\",\n \"addChannel.outputPriceLabel\": \"输出价\",\n \"addChannel.pricingTitle\": \"费率设定\",\n \"addChannel.pricingCurrencyLabel\": \"币种\",\n \"addChannel.pricingUnitLabel\": \"单位\",\n \"addChannel.defaultOutputTokensMeta\": \"默认输出 {tokens}\",\n \"addChannel.recommendedOutputTokensMeta\": \"推荐输出 {tokens}\",\n \"addChannel.modelPricingUnitHint\": \"每 100 万 tokens\",\n \"addChannel.embeddingCompatibilityTitle\": \"Embedding 兼容性\",\n \"addChannel.embeddingCompatibilityHint\": \"key 使用模型重定向后的实际上游模型名;fallback 只会在相同 embeddingSpaceId/实际模型、维度和归一化语义内发生。\",\n \"addChannel.embeddingCompatibilityRedirectTargets\": \"重定向目标\",\n \"addChannel.embeddingCompatibilityConfigured\": \"已配置 Embedding 空间\",\n \"addChannel.embeddingCapabilityModelLabel\": \"实际模型\",\n \"addChannel.embeddingCapabilityModelPlaceholder\": \"选择或输入上游 Embedding 模型\",\n \"addChannel.embeddingSpaceIdLabel\": \"向量空间 ID\",\n \"addChannel.embeddingSpaceIdPlaceholder\": \"留空时使用实际模型名\",\n \"addChannel.embeddingDimensionsLabel\": \"默认维度\",\n \"addChannel.embeddingSupportedDimensionsLabel\": \"支持的请求维度\",\n \"addChannel.embeddingSupportedDimensionsPlaceholder\": \"例如 1024, 1536\",\n \"addChannel.embeddingNormalizedLabel\": \"归一化\",\n \"addChannel.embeddingNormalizedUnknown\": \"未指定\",\n \"addChannel.embeddingNormalizedTrue\": \"已归一化\",\n \"addChannel.embeddingNormalizedFalse\": \"未归一化\",\n \"addChannel.embeddingCapabilitiesRowsInvalid\": \"请检查 Embedding 兼容性行:实际模型不能为空,维度必须为正整数,支持维度列表只能包含正整数。\",\n \"channelDiscovery.title\": \"发现可用配置\",\n \"channelDiscovery.hint\": \"用当前 Base URL 和 API Key 直接探测模型、协议、重定向和适配开关。\",\n \"channelDiscovery.button\": \"发现配置\",\n \"channelDiscovery.failed\": \"发现配置失败\",\n \"channelDiscovery.missingBaseUrl\": \"请先填写 Base URL。\",\n \"channelDiscovery.missingApiKey\": \"请先填写 API Key。\",\n \"channelDiscovery.missingServiceType\": \"请先选择服务类型。\",\n \"channelDiscovery.recommendedKind\": \"推荐 {kind}\",\n \"channelDiscovery.modelsFound\": \"模型 {count}\",\n \"channelDiscovery.mapping\": \"推荐重定向\",\n \"channelDiscovery.reasoning\": \"推荐思考强度\",\n \"channelDiscovery.reasoningNote\": \"思考强度是按源模型角色给出的默认值,发现流程目前不逐档测试。\",\n \"channelDiscovery.compat\": \"适配开关\",\n \"channelDiscovery.capabilities\": \"能力验证\",\n \"channelDiscovery.capabilityToolCalls\": \"工具调用\",\n \"channelDiscovery.capabilityVision\": \"图片输入\",\n \"channelDiscovery.capabilityImageGeneration\": \"Codex 生图工具\",\n \"channelDiscovery.capabilityThinkingPassback\": \"思考回传\",\n \"channelDiscovery.capabilitySupported\": \"支持\",\n \"channelDiscovery.capabilityUnsupported\": \"不支持\",\n \"channelDiscovery.capabilityRequired\": \"需要\",\n \"channelDiscovery.capabilityNotRequired\": \"不需要\",\n \"channelDiscovery.apply\": \"应用推荐\",\n \"channelDiscovery.applied\": \"已应用发现推荐,请检查后保存\",\n \"autopilot.quickAdd.alreadyAdded\": \"该 Base URL 已添加为渠道「{name}」,提交后会自动追加新密钥。\",\n \"autopilot.costPreference.balanced\": \"均衡\",\n \"autopilot.costPreference.cost_first\": \"成本优先\",\n \"autopilot.costPreference.quality_first\": \"质量优先\",\n \"autopilot.costPreferenceDesc.balanced\": \"在质量和成本之间取得平衡。\",\n \"autopilot.costPreferenceDesc.cost_first\": \"优先选择低成本渠道,适当容忍质量下降。\",\n \"autopilot.costPreferenceDesc.quality_first\": \"优先选择高质量供应商,容忍较高成本。\",\n \"autopilot.mode.active\": \"活跃\",\n \"autopilot.mode.assist\": \"辅助\",\n \"autopilot.mode.auto\": \"自动\",\n \"autopilot.mode.dry_run\": \"试运行\",\n \"autopilot.mode.off\": \"关闭\",\n \"autopilot.mode.shadow\": \"影子\",\n \"autopilot.modeDesc.assist\": \"辅助模式:重排候选列表顺序,但不改变最终选择。\",\n \"autopilot.modeDesc.auto\": \"全自动模式:过滤 + 重排,满足准入门槛后接管调度。\",\n \"autopilot.modeDesc.off\": \"完全关闭智能路由,不计算不记录路由 trace。\",\n \"autopilot.modeDesc.shadow\": \"只计算并记录路由决策,不影响真实调度(推荐)。\",\n \"autopilot.modePanel.confirmMessage\": \"即将切换到 {mode} 模式。该模式会影响真实调度结果,请确认您了解其行为。是否继续?\",\n \"autopilot.readiness.ready\": \"自动模式已达到上线门槛\",\n \"autopilot.readiness.collecting\": \"正在积累自动模式安全样本\",\n \"autopilot.readiness.progress\": \"有效样本 {samples}/{requiredSamples},连续观测 {hours}/{requiredHours} 小时\",\n \"autopilot.readiness.successRate\": \"成功率\",\n \"autopilot.readiness.fallbackRate\": \"Fallback\",\n \"autopilot.readiness.failOpenRate\": \"Fail-open\",\n \"autopilot.readiness.lastRollback\": \"{time} 检测到连续 SLO 回归,系统已自动从 auto 降回 assist。\",\n \"autopilot.readiness.reason.telemetry_unavailable\": \"路由遥测不可用\",\n \"autopilot.readiness.reason.telemetry_query_failed\": \"路由遥测查询失败\",\n \"autopilot.readiness.reason.insufficient_samples\": \"有效样本不足\",\n \"autopilot.readiness.reason.insufficient_observation_time\": \"连续观测时间不足\",\n \"autopilot.readiness.reason.success_rate_below_threshold\": \"成功率低于 95%\",\n \"autopilot.readiness.reason.fallback_rate_above_threshold\": \"Fallback 率高于 10%\",\n \"autopilot.readiness.reason.failopen_rate_above_threshold\": \"Fail-open 率高于 2%\",\n \"autopilot.readiness.reason.insufficient_comparison_samples\": \"近期与基线对比样本不足\",\n \"autopilot.readiness.reason.recent_success_regression\": \"近期成功率明显下降\",\n \"autopilot.readiness.reason.recent_fallback_regression\": \"近期 Fallback 率明显上升\",\n \"autopilot.readiness.reason.recent_latency_regression\": \"近期 p95 延迟明显上升\",\n \"autopilot.modePanel.confirmTitle\": \"确认切换模式\",\n \"autopilot.modePanel.costPreference\": \"价格偏好\",\n \"autopilot.modePanel.killSwitch\": \"急停开关(Kill Switch)\",\n \"autopilot.modePanel.killSwitchActive\": \"急停开关已激活,智能路由已强制关闭。请先关闭急停开关以启用其他模式。\",\n \"autopilot.modePanel.killSwitchHint\": \"开启后强制关闭智能路由,优先级高于路由模式选择。\",\n \"autopilot.modePanel.reset\": \"重置\",\n \"autopilot.modePanel.routingMode\": \"路由模式\",\n \"autopilot.modePanel.save\": \"保存配置\",\n \"autopilot.modePanel.title\": \"全局策略\",\n \"autopilot.title\": \"智能路由 Autopilot\",\n \"autopilot.traceStats.mismatchRate\": \"不一致率\",\n \"autopilot.traceStats.mismatches\": \"不一致数\",\n \"autopilot.traceStats.modeDistribution\": \"按模式分布\",\n \"autopilot.traceStats.taskClassDistribution\": \"按任务类别分布\",\n \"autopilot.traceStats.title\": \"路由追踪统计\",\n \"autopilot.traceStats.total\": \"总追踪数\",\n \"autopilot.traceTable.candidates\": \"候选渠道\",\n \"autopilot.traceTable.col.kind\": \"类型\",\n \"autopilot.traceTable.col.match\": \"一致\",\n \"autopilot.traceTable.col.mode\": \"模式\",\n \"autopilot.traceTable.col.outcome\": \"结果\",\n \"autopilot.traceTable.col.model\": \"模型\",\n \"autopilot.traceTable.col.shadowVsActual\": \"Shadow vs 实际\",\n \"autopilot.traceTable.col.taskClass\": \"任务类别\",\n \"autopilot.traceTable.col.time\": \"时间\",\n \"autopilot.traceTable.empty\": \"暂无追踪记录\",\n \"autopilot.traceTable.mismatchOnly\": \"只看不一致\",\n \"autopilot.traceTable.no\": \"否\",\n \"autopilot.traceTable.sortReasons\": \"排序原因\",\n \"autopilot.traceTable.title\": \"路由决策追踪\",\n \"autopilot.traceTable.yes\": \"是\",\n \"healthCenter.col.channel\": \"渠道\",\n \"healthCenter.col.endpoints\": \"Endpoints\",\n \"healthCenter.col.expand\": \"详情\",\n \"healthCenter.col.kind\": \"类型\",\n \"healthCenter.col.status\": \"状态\",\n \"healthCenter.col.successRate\": \"成功率\",\n \"healthCenter.detail.baseUrl\": \"Base URL\",\n \"healthCenter.detail.confidence\": \"置信度\",\n \"healthCenter.detail.consecutiveFail\": \"连续失败\",\n \"healthCenter.detail.evidence\": \"证据\",\n \"healthCenter.detail.keyHash\": \"Key\",\n \"healthCenter.detail.lastSuccess\": \"最近成功\",\n \"healthCenter.detail.p95\": \"P95 延迟\",\n \"healthCenter.detail.firstByteP95\": \"P95 首字节\",\n \"healthCenter.detail.qualityTier\": \"质量\",\n \"healthCenter.detail.speedTier\": \"速度\",\n \"healthCenter.detail.stabilityTier\": \"稳定性\",\n \"healthCenter.detail.successRate15m\": \"成功率(15m)\",\n \"healthCenter.detail.successRate1h\": \"成功率(1h)\",\n \"healthCenter.detail.suggestedAction\": \"建议操作\",\n \"healthCenter.detail.updatedAt\": \"更新时间\",\n \"healthCenter.kind.chat\": \"Chat\",\n \"healthCenter.kind.gemini\": \"Gemini\",\n \"healthCenter.kind.images\": \"Images\",\n \"healthCenter.kind.messages\": \"Messages\",\n \"healthCenter.kind.responses\": \"Responses\",\n \"healthCenter.kind.vectors\": \"Vectors\",\n \"healthCenter.noChannels\": \"暂无渠道健康数据\",\n \"healthCenter.state.dead\": \"死亡\",\n \"healthCenter.state.degraded\": \"降级\",\n \"healthCenter.state.healthy\": \"健康\",\n \"healthCenter.state.limited\": \"限流\",\n \"healthCenter.state.misconfigured\": \"疑似配置错\",\n \"healthCenter.state.unknown\": \"未知\",\n \"healthCenter.title\": \"渠道健康中心\",\n \"healthCenter.totalChannels\": \"渠道总数\",\n \"healthCenter.totalEndpoints\": \"Endpoint 总数\",\n \"nav.health\": \"健康中心\",\n \"nav.healthDesc\": \"渠道健康状态总览\",\n \"nav.autopilot\": \"智能路由\",\n \"nav.autopilotDesc\": \"自动托管调度策略\",\n \"tab.healthTitle\": \"渠道健康中心\",\n \"tab.autopilotTitle\": \"智能路由 Autopilot\",\n \"channelHealth.stateLabel\": \"健康状态\",\n \"channelHealth.endpointsHealthy\": \"个端点健康\",\n \"channelHealth.avgSuccessRate\": \"平均成功率\",\n \"channelHealth.inconsistentWarning\": \"{count} 个端点状态不一致\",\n \"healthCenter.changelog.title\": \"最近变更\",\n \"healthCenter.changelog.empty\": \"暂无变更记录\",\n \"healthCenter.changelog.live\": \"实时\",\n \"healthCenter.changelog.connecting\": \"连接中…\",\n \"healthCenter.changelog.disconnected\": \"实时更新已断开,正在重连…\",\n \"healthCenter.changelog.type.healthChanged\": \"健康状态变化\",\n \"healthCenter.changelog.type.profileUpdated\": \"画像更新\",\n \"healthCenter.changelog.type.discoveryCompleted\": \"自动发现完成\",\n \"healthCenter.changelog.type.autoMappingApplied\": \"自动写入模型配置\",\n \"channelHealth.originOfficial\": \"官方 API\",\n \"channelHealth.originRelay\": \"中转\",\n \"channelHealth.originCommunity\": \"公益\",\n \"channelHealth.originUnknown\": \"来源未知\",\n \"channelHealth.poolFree\": \"白嫖池\",\n \"channelHealth.poolTemp\": \"临时池\",\n \"cockpitOverview.title\": \"驾驶舱总览\",\n \"cockpitOverview.health\": \"健康概览\",\n \"cockpitOverview.channels\": \"渠道数\",\n \"cockpitOverview.endpoints\": \"Endpoint 数\",\n \"cockpitOverview.subscriptions\": \"订阅概览\",\n \"cockpitOverview.totalSubscriptions\": \"订阅总数\",\n \"cockpitOverview.manualIntents\": \"人工意图\",\n \"cockpitOverview.activeIntents\": \"活跃意图\",\n \"cockpitOverview.totalIntents\": \"意图总数\",\n \"cockpitOverview.todoList\": \"待办事项\",\n \"cockpitOverview.empty\": \"暂无数据,请先配置渠道和订阅。\",\n \"cockpitOverview.recommendations\": \"渠道推荐\",\n \"cockpitOverview.noRecommendations\": \"暂无推荐,用量数据积累后会在此展示建议。\",\n \"cockpitOverview.usageCount\": \"近期使用 {count} 次\",\n \"cockpitOverview.currentChannel\": \"当前主用渠道\",\n \"cockpitOverview.recommendedChannel\": \"推荐尝试\",\n \"cockpitOverview.scoreDelta\": \"综合分差 +{delta}\",\n \"cockpitOverview.activeTrials\": \"活跃试用意图\",\n \"cockpitOverview.noActiveTrials\": \"暂无活跃试用意图。可在渠道中心点击“试用”创建。\",\n \"cockpitOverview.trialExpired\": \"已到期\",\n \"cockpitOverview.trialStats\": \"命中 {hit} · 成功率 {rate} · 回退 {fallback}\",\n \"cockpitOverview.trialStatsLabel\": \"试用统计\",\n \"cockpitOverview.remaining\": \"剩余时间\",\n \"cockpitOverview.status\": \"状态\",\n \"manualIntent.field.intentType\": \"意图类型\",\n \"manualIntent.field.channelUid\": \"渠道 UID(可选)\",\n \"manualIntent.field.model\": \"模型(可选)\",\n \"manualIntent.intentType.model_trial\": \"模型试用\",\n \"manualIntent.intentType.channel_trial\": \"渠道试用\",\n \"manualIntent.intentType.endpoint_trial\": \"Endpoint 试用\",\n \"manualIntent.intentType.session_pin\": \"会话锁定\",\n \"manualIntent.status.active\": \"活跃\",\n \"manualIntent.status.expired\": \"已到期\",\n \"manualIntent.status.exhausted\": \"已耗尽\",\n \"manualIntent.status.disabled\": \"已停用\",\n \"manualIntent.actions.end\": \"结束\",\n \"manualIntent.field.channelKind\": \"渠道类型\",\n \"manualIntent.field.name\": \"名称(可选)\",\n \"manualIntent.field.metricsKey\": \"Endpoint 指标键(可选)\",\n \"manualIntent.field.mappedModel\": \"映射模型(可选)\",\n \"manualIntent.field.sessionId\": \"会话 ID\",\n \"manualIntent.field.agentRoles\": \"Agent 角色\",\n \"manualIntent.field.taskClasses\": \"任务类别\",\n \"manualIntent.field.trafficPercent\": \"流量比例\",\n \"manualIntent.field.ttlMinutes\": \"有效时长(分钟)\",\n \"manualIntent.field.maxRequests\": \"最大请求数\",\n \"manualIntent.field.maxEstimatedCost\": \"最大预估成本($)\",\n \"manualIntent.field.fallbackOnFailure\": \"失败时回退\",\n \"manualIntent.field.requireHardConstraints\": \"强制硬性约束\",\n \"manualIntent.field.reason\": \"备注\",\n \"manualIntent.agentRole.main\": \"主 Agent\",\n \"manualIntent.agentRole.subagent\": \"子 Agent\",\n \"manualIntent.taskClass.supervisor\": \"监督\",\n \"manualIntent.taskClass.worker\": \"工作\",\n \"manualIntent.taskClass.lightweight\": \"轻量\",\n \"manualIntent.taskClass.vision\": \"视觉\",\n \"manualIntent.taskClass.long_context\": \"长上下文\",\n \"manualIntent.taskClass.image_generation\": \"图像生成\",\n \"manualIntent.taskClass.embedding\": \"向量嵌入\",\n \"manualIntent.validation.channelKindRequired\": \"渠道类型为必填项\",\n \"manualIntent.validation.sessionIdRequired\": \"会话锁定需要填写会话 ID\",\n \"manualIntent.actions.create\": \"创建试用\",\n \"manualIntent.actions.cancel\": \"取消\",\n \"manualIntent.error.createFailed\": \"创建试用意图失败\",\n \"subscription.newApi.maxGroupMultiplier\": \"最高分组倍率\",\n \"subscription.newApi.maxGroupMultiplierHint\": \"自动接入倍率不高于 {limit} 的全部分组。\",\n \"subscription.newApi.invalidMaxGroupMultiplier\": \"请输入有限且大于等于 0 的数值。\",\n \"subscription.newApi.excludedGroups\": \"{count} 个分组超过 {limit} 上限,将被排除。\",\n \"subscription.newApi.groupFetchError\": \"获取分组倍率失败:\",\n \"subscription.newApi.noEligibleGroups\": \"没有倍率不高于 {limit} 的可接入分组。\",\n \"subscription.newApi.eligibleGroups\": \"可接入 {count} 个分组:\",\n \"subscription.management.title\": \"订阅管理\",\n \"subscription.management.description\": \"查看余额、到账规则、刷新结果与每把密钥的倍率状态。\",\n \"subscription.management.zeroBalance\": \"余额为 0\",\n \"subscription.management.refresh\": \"刷新\",\n \"subscription.management.refreshed\": \"订阅已刷新。\",\n \"subscription.management.empty\": \"暂无已管理订阅。\",\n \"billingTerms.unconfigured\": \"未配置到账规则\",\n \"billingTerms.preview\": \"支付 {payment} {paymentUnit},获得 {credit} {creditUnit}\",\n \"billingTerms.edit\": \"编辑到账规则\",\n \"billingTerms.paymentAmount\": \"支付金额\",\n \"billingTerms.paymentUnit\": \"支付单位\",\n \"billingTerms.creditAmount\": \"到账金额\",\n \"billingTerms.creditUnit\": \"到账单位\",\n \"billingTerms.invalidAmount\": \"金额必须是有限且大于 0 的数值。\",\n \"billingTerms.save\": \"保存到账规则\",\n \"billingTerms.saved\": \"到账规则已保存。\",\n \"exchangeRates.title\": \"汇率管理\",\n \"exchangeRates.description\": \"全量替换汇率报价图,并查看派生的 USD 单位价格。\",\n \"exchangeRates.add\": \"新增报价\",\n \"exchangeRates.sourceAmount\": \"源金额\",\n \"exchangeRates.sourceUnit\": \"源单位\",\n \"exchangeRates.targetAmount\": \"目标金额\",\n \"exchangeRates.targetUnit\": \"目标单位\",\n \"exchangeRates.empty\": \"暂无汇率报价;保存空列表会清空全部汇率。\",\n \"exchangeRates.snapshot\": \"快照 v{version}\",\n \"exchangeRates.invalidQuote\": \"每条报价都需要正的有限金额和非空单位。\",\n \"exchangeRates.replace\": \"全量替换汇率\",\n \"exchangeRates.saved\": \"汇率已保存。\",\n \"multiplier.group\": \"分组倍率\",\n \"multiplier.max\": \"最高倍率\",\n \"multiplier.groupName\": \"分组\",\n \"multiplier.statusLabel\": \"状态\",\n \"multiplier.reason\": \"原因\",\n \"multiplier.ttl\": \"有效期至\",\n \"multiplier.eligible\": \"可用\",\n \"multiplier.ineligible\": \"不可用\",\n \"multiplier.invalid\": \"倍率必须是有限且大于等于 0 的数值。\",\n \"multiplier.save\": \"保存倍率\",\n \"multiplier.status.manual\": \"手动\",\n \"multiplier.status.fresh\": \"新鲜\",\n \"multiplier.status.stale\": \"已过期\",\n \"multiplier.status.over_limit\": \"超过上限\",\n \"multiplier.status.sync_error\": \"同步错误\",\n \"multiplier.status.relink_required\": \"需要重新关联\",\n \"multiplier.status.remote_group_missing\": \"远端分组已消失\"\n}\n"}}, "desktop/frontend/src/utils/quick-input-parser.test.ts": {"e972d82e6b4b3edf23f948edbd0db2d4c80db655": {"t": 1785809894.2772758, "n": 3586, "h": "69b67866802da36c", "c": "import { describe, expect, it } from 'vitest'\nimport { parseQuickInput } from './quick-input-parser'\n\ndescribe('quick input service type detection', () => {\n it('detects Claude Messages endpoints and strips the protocol path', () => {\n const result = parseQuickInput('https://api.example.com/v1/messages sk-key1234567890')\n\n expect(result.detectedServiceType).toBe('claude')\n expect(result.detectedBaseUrl).toBe('https://api.example.com')\n })\n\n it('detects OpenAI Chat endpoints and strips the protocol path', () => {\n const result = parseQuickInput('https://api.example.com/v1/chat/completions sk-key1234567890')\n\n expect(result.detectedServiceType).toBe('openai')\n expect(result.detectedBaseUrl).toBe('https://api.example.com')\n })\n\n it('detects OpenAI Embeddings endpoints and strips the protocol path', () => {\n const result = parseQuickInput('https://api.example.com/v1/embeddings sk-key1234567890')\n\n expect(result.detectedServiceType).toBe('openai')\n expect(result.detectedBaseUrl).toBe('https://api.example.com')\n })\n\n it('detects Responses endpoints and strips the protocol path', () => {\n const result = parseQuickInput('https://api.example.com/v1/responses sk-key1234567890')\n\n expect(result.detectedServiceType).toBe('responses')\n expect(result.detectedBaseUrl).toBe('https://api.example.com')\n })\n\n it('detects Gemini generateContent endpoints and strips the protocol path', () => {\n const result = parseQuickInput('https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro:generateContent AIza1234567890abcdefghijklmnopqrstuv')\n\n expect(result.detectedServiceType).toBe('gemini')\n expect(result.detectedBaseUrl).toBe('https://generativelanguage.googleapis.com')\n })\n\n it('detects common Anthropic and Claude URL hints without stripping non-protocol paths', () => {\n const result = parseQuickInput('https://relay.example.com/anthropic sk-key1234567890')\n\n expect(result.detectedServiceType).toBe('claude')\n expect(result.detectedBaseUrl).toBe('https://relay.example.com/anthropic')\n })\n\n it('strips third-party dashboard paths such as /api-keys/create back to origin', () => {\n const result = parseQuickInput('https://aihub.top/api-keys/create sk-key1234567890')\n\n expect(result.detectedServiceType).toBeNull()\n expect(result.detectedBaseUrl).toBe('https://aihub.top')\n })\n\n it('detects built-in preset URLs that do not include protocol keywords', () => {\n expect(parseQuickInput('https://cp.compshare.cn sk-key1234567890').detectedServiceType).toBe('claude')\n expect(parseQuickInput('https://openrouter.ai/api sk-key1234567890').detectedServiceType).toBe('claude')\n expect(parseQuickInput('https://openrouter.ai/api/v1 sk-key1234567890').detectedServiceType).toBe('openai')\n expect(parseQuickInput('https://api.kimi.com/coding/v1 sk-key1234567890').detectedServiceType).toBe('openai')\n expect(parseQuickInput('https://maas-coding-api.cn-huabei-1.xf-yun.com/anthropic sk-key1234567890').detectedServiceType).toBe('claude')\n expect(parseQuickInput('https://maas-coding-api.cn-huabei-1.xf-yun.com/v2 sk-key1234567890').detectedServiceType).toBe('openai')\n expect(parseQuickInput('https://maas-coding-api.cn-huabei-1.xf-yun.com/v1/responses sk-key1234567890').detectedServiceType).toBe('responses')\n expect(parseQuickInput('https://maas-api.cn-huabei-1.xf-yun.com/anthropic sk-key1234567890').detectedServiceType).toBe('claude')\n expect(parseQuickInput('https://maas-api.cn-huabei-1.xf-yun.com/v2 sk-key1234567890').detectedServiceType).toBe('openai')\n })\n})\n"}}}}